Сохраняемся
This commit is contained in:
+350
-23
@@ -20,6 +20,7 @@ from scene_generator import (
|
|||||||
apply_transform,
|
apply_transform,
|
||||||
export_npy_float64,
|
export_npy_float64,
|
||||||
export_obj,
|
export_obj,
|
||||||
|
generate_pipe,
|
||||||
parse_obj_labeled_points,
|
parse_obj_labeled_points,
|
||||||
parse_obj_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")
|
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
|
# 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]]:
|
def normalize_object_points(points: list[list[float]]) -> list[list[float]]:
|
||||||
xs = [p[0] for p in points]
|
xs = [p[0] for p in points]
|
||||||
ys = [p[1] for p in points]
|
ys = [p[1] for p in points]
|
||||||
@@ -286,6 +333,169 @@ def _place_object_in_scene(
|
|||||||
return world, info
|
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(
|
def _rasterize_object_hits(
|
||||||
world_pts: list[list[float]],
|
world_pts: list[list[float]],
|
||||||
meta: dict[str, Any],
|
meta: dict[str, Any],
|
||||||
@@ -368,18 +578,72 @@ def _cast_echosounder_returns(
|
|||||||
# Balance plan + single scene
|
# 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.
|
"""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))
|
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)
|
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)
|
rng.shuffle(labels)
|
||||||
return labels
|
return labels
|
||||||
|
|
||||||
@@ -390,6 +654,7 @@ def generate_sonar_scene(
|
|||||||
visibility: str = "absent",
|
visibility: str = "absent",
|
||||||
object_points: list[list[float]] | None = None,
|
object_points: list[list[float]] | None = None,
|
||||||
object_scale: float = 1.0,
|
object_scale: float = 1.0,
|
||||||
|
object_kind: str | None = None,
|
||||||
beam_count: int = 45,
|
beam_count: int = 45,
|
||||||
length_count: int | None = None,
|
length_count: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -397,11 +662,13 @@ def generate_sonar_scene(
|
|||||||
|
|
||||||
visibility in absent|nearly_hidden|partial|visible.
|
visibility in absent|nearly_hidden|partial|visible.
|
||||||
Scene size is always beam_count × length_count returns.
|
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))
|
rng = random.Random(int(seed))
|
||||||
if visibility not in ("absent",) + VISIBILITY_TIERS:
|
if visibility not in ("absent",) + VISIBILITY_TIERS:
|
||||||
raise ValueError(f"Unknown visibility: {visibility}")
|
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.")
|
raise ValueError("object_points required when visibility is not absent.")
|
||||||
|
|
||||||
meta = _build_seafloor_meta(rng, beam_count=beam_count, length_count=length_count)
|
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_info: dict[str, Any] | None = None
|
||||||
object_hits: dict[tuple[int, int], float] | None = None
|
object_hits: dict[tuple[int, int], float] | None = None
|
||||||
if visibility != "absent":
|
if visibility != "absent":
|
||||||
world, object_info = _place_object_in_scene(
|
if kind == "pipe":
|
||||||
rng,
|
world, object_info = _place_pipeline_in_scene(
|
||||||
meta,
|
rng,
|
||||||
visibility,
|
meta,
|
||||||
object_points,
|
visibility,
|
||||||
object_scale=object_scale,
|
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_hits = _rasterize_object_hits(world, meta)
|
||||||
object_info["hitCellCount"] = len(object_hits)
|
object_info["hitCellCount"] = len(object_hits)
|
||||||
object_info["keptCount"] = len(object_hits)
|
object_info["keptCount"] = len(object_hits)
|
||||||
@@ -533,6 +808,10 @@ def build_run_manifest(
|
|||||||
object_vertex_count: int,
|
object_vertex_count: int,
|
||||||
stats: dict[str, Any],
|
stats: dict[str, Any],
|
||||||
written: list[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]:
|
) -> dict[str, Any]:
|
||||||
model_filename = _normalize_model_filename(object_name)
|
model_filename = _normalize_model_filename(object_name)
|
||||||
return {
|
return {
|
||||||
@@ -551,6 +830,10 @@ def build_run_manifest(
|
|||||||
"beamCount": int(beam_count),
|
"beamCount": int(beam_count),
|
||||||
"lengthCount": int(length_count),
|
"lengthCount": int(length_count),
|
||||||
"objectName": model_filename,
|
"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),
|
"objectVertexCount": int(object_vertex_count),
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
@@ -850,12 +1133,17 @@ def iter_generate_dataset(
|
|||||||
count: int = 5,
|
count: int = 5,
|
||||||
seed: int = 42,
|
seed: int = 42,
|
||||||
output_dir: str | Path = "sonar_dataset",
|
output_dir: str | Path = "sonar_dataset",
|
||||||
object_points: list[list[float]],
|
object_points: list[list[float]] | None = None,
|
||||||
object_name: str | None = None,
|
object_name: str | None = None,
|
||||||
|
object_kind: str | None = None,
|
||||||
object_scale: float = 1.0,
|
object_scale: float = 1.0,
|
||||||
object_scale_is_max: bool = False,
|
object_scale_is_max: bool = False,
|
||||||
beam_count: int = 45,
|
beam_count: int = 45,
|
||||||
length_count: int | None = None,
|
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.
|
"""Yield NDJSON-friendly progress events, then a final ``done`` payload.
|
||||||
|
|
||||||
@@ -869,8 +1157,13 @@ def iter_generate_dataset(
|
|||||||
raise ValueError("count must be >= 1")
|
raise ValueError("count must be >= 1")
|
||||||
if count > 5000:
|
if count > 5000:
|
||||||
raise ValueError("count must be <= 5000")
|
raise ValueError("count must be <= 5000")
|
||||||
if not object_points or len(object_points) < 3:
|
kind = (object_kind or "").strip().lower() or None
|
||||||
raise ValueError("A valid .obj model with at least 3 vertices is required.")
|
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)
|
object_scale = float(object_scale)
|
||||||
if object_scale <= 0:
|
if object_scale <= 0:
|
||||||
raise ValueError("object_scale must be > 0")
|
raise ValueError("object_scale must be > 0")
|
||||||
@@ -890,13 +1183,24 @@ 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")
|
||||||
|
|
||||||
|
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)
|
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)
|
|
||||||
|
|
||||||
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]] = []
|
written: list[dict[str, Any]] = []
|
||||||
stats = {
|
stats = {
|
||||||
"total": count,
|
"total": count,
|
||||||
@@ -911,6 +1215,7 @@ def iter_generate_dataset(
|
|||||||
preview_points: list[list[float]] | None = None
|
preview_points: list[list[float]] | None = None
|
||||||
preview_stem: str | None = None
|
preview_stem: str | None = None
|
||||||
preview_has_object = False
|
preview_has_object = False
|
||||||
|
object_vertex_count = len(template)
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "start",
|
"type": "start",
|
||||||
@@ -936,9 +1241,12 @@ def iter_generate_dataset(
|
|||||||
visibility=visibility,
|
visibility=visibility,
|
||||||
object_points=template,
|
object_points=template,
|
||||||
object_scale=scene_scale,
|
object_scale=scene_scale,
|
||||||
|
object_kind=kind,
|
||||||
beam_count=beam_count,
|
beam_count=beam_count,
|
||||||
length_count=length_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)
|
area, scene_no, stem = scene_index_to_area_name(i)
|
||||||
paths = write_scene_files(scene, run_dir, stem)
|
paths = write_scene_files(scene, run_dir, stem)
|
||||||
|
|
||||||
@@ -995,9 +1303,14 @@ def iter_generate_dataset(
|
|||||||
"beamCount": beam_count,
|
"beamCount": beam_count,
|
||||||
"lengthCount": length_count,
|
"lengthCount": length_count,
|
||||||
"objectName": object_name,
|
"objectName": object_name,
|
||||||
|
"objectKind": kind,
|
||||||
"objectScale": object_scale,
|
"objectScale": object_scale,
|
||||||
"objectScaleIsMax": object_scale_is_max,
|
"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"},
|
"classLabels": {"0": "background", "1": "object"},
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"written": written,
|
"written": written,
|
||||||
@@ -1017,6 +1330,10 @@ def iter_generate_dataset(
|
|||||||
object_vertex_count=len(template),
|
object_vertex_count=len(template),
|
||||||
stats=stats,
|
stats=stats,
|
||||||
written=written,
|
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)
|
write_run_manifest(run_dir, manifest)
|
||||||
result["settingsPath"] = str(run_dir / DATASET_RUN_FILENAME)
|
result["settingsPath"] = str(run_dir / DATASET_RUN_FILENAME)
|
||||||
@@ -1028,12 +1345,17 @@ def generate_dataset(
|
|||||||
count: int = 5,
|
count: int = 5,
|
||||||
seed: int = 42,
|
seed: int = 42,
|
||||||
output_dir: str | Path = "sonar_dataset",
|
output_dir: str | Path = "sonar_dataset",
|
||||||
object_points: list[list[float]],
|
object_points: list[list[float]] | None = None,
|
||||||
object_name: str | None = None,
|
object_name: str | None = None,
|
||||||
|
object_kind: str | None = None,
|
||||||
object_scale: float = 1.0,
|
object_scale: float = 1.0,
|
||||||
object_scale_is_max: bool = False,
|
object_scale_is_max: bool = False,
|
||||||
beam_count: int = 45,
|
beam_count: int = 45,
|
||||||
length_count: int | None = None,
|
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]:
|
) -> dict[str, Any]:
|
||||||
"""Generate `count` unique scenes into a new timestamped run folder under output_dir."""
|
"""Generate `count` unique scenes into a new timestamped run folder under output_dir."""
|
||||||
result: dict[str, Any] | None = None
|
result: dict[str, Any] | None = None
|
||||||
@@ -1043,10 +1365,15 @@ def generate_dataset(
|
|||||||
output_dir=output_dir,
|
output_dir=output_dir,
|
||||||
object_points=object_points,
|
object_points=object_points,
|
||||||
object_name=object_name,
|
object_name=object_name,
|
||||||
|
object_kind=object_kind,
|
||||||
object_scale=object_scale,
|
object_scale=object_scale,
|
||||||
object_scale_is_max=object_scale_is_max,
|
object_scale_is_max=object_scale_is_max,
|
||||||
beam_count=beam_count,
|
beam_count=beam_count,
|
||||||
length_count=length_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":
|
if event.get("type") == "done":
|
||||||
result = event["result"]
|
result = event["result"]
|
||||||
|
|||||||
+60
-9
@@ -22,7 +22,10 @@ from dataset_generator import (
|
|||||||
iter_generate_dataset,
|
iter_generate_dataset,
|
||||||
list_dataset_runs,
|
list_dataset_runs,
|
||||||
load_dataset_run,
|
load_dataset_run,
|
||||||
|
list_object_presets,
|
||||||
load_object_points_from_obj_text,
|
load_object_points_from_obj_text,
|
||||||
|
load_preset_object_points,
|
||||||
|
OBJECT_PRESETS,
|
||||||
load_scene_preview,
|
load_scene_preview,
|
||||||
resolve_output_dir,
|
resolve_output_dir,
|
||||||
)
|
)
|
||||||
@@ -137,11 +140,18 @@ class DatasetLoadBody(BaseModel):
|
|||||||
|
|
||||||
class MlePrepareBody(BaseModel):
|
class MlePrepareBody(BaseModel):
|
||||||
seed: int = 42
|
seed: int = 42
|
||||||
sizeX: float = 40.0
|
sizeX: float | None = None
|
||||||
sizeY: float = 60.0
|
sizeY: float | None = None
|
||||||
resX: int = 80
|
resX: int = 80
|
||||||
resY: int = 120
|
resY: int = 120
|
||||||
outputDir: str = "mle_runs"
|
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
|
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
|
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")
|
@app.post("/api/dataset/generate")
|
||||||
async def dataset_generate(
|
async def dataset_generate(
|
||||||
count: int = Form(5),
|
count: int = Form(5),
|
||||||
@@ -484,15 +499,36 @@ async def dataset_generate(
|
|||||||
objectScaleIsMax: bool = Form(False),
|
objectScaleIsMax: bool = Form(False),
|
||||||
beamCount: int = Form(45),
|
beamCount: int = Form(45),
|
||||||
lengthCount: int | None = Form(None),
|
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:
|
) -> StreamingResponse:
|
||||||
filename = (model.filename or "").strip()
|
preset = (modelPreset or "").strip().lower()
|
||||||
if not filename.lower().endswith(".obj"):
|
filename = ((model.filename if model else None) or "").strip()
|
||||||
raise HTTPException(status_code=400, detail="Upload a .obj 3D model file.")
|
object_kind: str | None = None
|
||||||
|
object_points: list[list[float]] | None = None
|
||||||
try:
|
try:
|
||||||
raw = await model.read()
|
if preset:
|
||||||
text = raw.decode("utf-8", errors="ignore")
|
if preset not in OBJECT_PRESETS:
|
||||||
object_points = load_object_points_from_obj_text(text)
|
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:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from 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",
|
output_dir=outputDir or "sonar_dataset",
|
||||||
object_points=object_points,
|
object_points=object_points,
|
||||||
object_name=filename,
|
object_name=filename,
|
||||||
|
object_kind=object_kind,
|
||||||
object_scale=objectScale,
|
object_scale=objectScale,
|
||||||
object_scale_is_max=objectScaleIsMax,
|
object_scale_is_max=objectScaleIsMax,
|
||||||
beam_count=beamCount,
|
beam_count=beamCount,
|
||||||
length_count=lengthCount,
|
length_count=lengthCount,
|
||||||
|
absent_pct=absentPct,
|
||||||
|
nearly_hidden_pct=nearlyHiddenPct,
|
||||||
|
partial_pct=partialPct,
|
||||||
|
visible_pct=visiblePct,
|
||||||
):
|
):
|
||||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -802,6 +843,14 @@ def mle_spa() -> FileResponse:
|
|||||||
@app.post("/api/mle/prepare")
|
@app.post("/api/mle/prepare")
|
||||||
def mle_prepare(body: MlePrepareBody) -> dict[str, Any]:
|
def mle_prepare(body: MlePrepareBody) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
|
corridor = {
|
||||||
|
"auvX": body.auvX,
|
||||||
|
"auvY": body.auvY,
|
||||||
|
"headingDeg": body.auvHeadingDeg,
|
||||||
|
"surveyLength": body.surveyLength,
|
||||||
|
"auvDepth": body.auvDepth,
|
||||||
|
"swathAngleDeg": body.swathAngleDeg,
|
||||||
|
}
|
||||||
result = prepare_mle_scene(
|
result = prepare_mle_scene(
|
||||||
seed=body.seed,
|
seed=body.seed,
|
||||||
size_x=body.sizeX,
|
size_x=body.sizeX,
|
||||||
@@ -810,6 +859,8 @@ def mle_prepare(body: MlePrepareBody) -> dict[str, Any]:
|
|||||||
res_y=body.resY,
|
res_y=body.resY,
|
||||||
output_dir=body.outputDir or "mle_runs",
|
output_dir=body.outputDir or "mle_runs",
|
||||||
settings=body.settings,
|
settings=body.settings,
|
||||||
|
relief_scale_pct=body.reliefScalePct,
|
||||||
|
corridor=corridor,
|
||||||
)
|
)
|
||||||
if body.settings:
|
if body.settings:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+135
-35
@@ -81,42 +81,103 @@ def _height_at(
|
|||||||
def build_seafloor_params(
|
def build_seafloor_params(
|
||||||
seed: int = 42,
|
seed: int = 42,
|
||||||
*,
|
*,
|
||||||
size_x: float = 40.0,
|
size_x: float | None = None,
|
||||||
size_y: float = 60.0,
|
size_y: float | None = None,
|
||||||
|
relief_scale_pct: float = 20.0,
|
||||||
|
corridor: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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))
|
rng = random.Random(int(seed))
|
||||||
size_x = max(4.0, float(size_x))
|
relief_scale_pct = max(1.0, min(100.0, float(relief_scale_pct)))
|
||||||
size_y = max(4.0, float(size_y))
|
|
||||||
|
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)
|
base_z = rng.uniform(-8.0, -3.0)
|
||||||
amplitude = rng.uniform(0.15, 0.6)
|
amplitude = rng.uniform(0.05, 0.18)
|
||||||
frequency = rng.uniform(0.15, 0.55)
|
frequency = rng.uniform(0.08, 0.25)
|
||||||
hills = [
|
|
||||||
(
|
# Characteristic feature size = pct of yellow strip width.
|
||||||
rng.uniform(-size_x * 0.4, size_x * 0.4),
|
feature_scale = strip_width * (relief_scale_pct / 100.0)
|
||||||
rng.uniform(-size_y * 0.4, size_y * 0.4),
|
feature_scale = max(0.15, feature_scale)
|
||||||
rng.uniform(0.3, 1.4),
|
|
||||||
rng.uniform(2.0, 8.0),
|
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))
|
|
||||||
]
|
n_hills = rng.randint(2, 4)
|
||||||
valleys = [
|
n_valleys = rng.randint(1, 3)
|
||||||
(
|
# More bumps when features are smaller so the strip stays filled.
|
||||||
rng.uniform(-size_x * 0.4, size_x * 0.4),
|
density = max(0.35, min(1.6, 20.0 / max(relief_scale_pct, 1.0)))
|
||||||
rng.uniform(-size_y * 0.4, size_y * 0.4),
|
n_bumps = int(round(rng.uniform(10, 18) * density))
|
||||||
rng.uniform(0.2, 0.9),
|
n_bumps = max(6, min(36, n_bumps))
|
||||||
rng.uniform(2.0, 7.0),
|
|
||||||
)
|
hills = []
|
||||||
for _ in range(rng.randint(1, 4))
|
for _ in range(n_hills):
|
||||||
]
|
t = rng.uniform(0.0, survey_length)
|
||||||
bumps = [
|
lat = rng.uniform(-half_w * 0.85, half_w * 0.85)
|
||||||
(
|
x, y = point_in_strip(t, lat)
|
||||||
rng.uniform(-size_x * 0.45, size_x * 0.45),
|
rad = feature_scale * rng.uniform(0.9, 1.8)
|
||||||
rng.uniform(-size_y * 0.45, size_y * 0.45),
|
amp = feature_scale * rng.uniform(0.25, 0.7)
|
||||||
rng.uniform(0.05, 0.4),
|
hills.append((x, y, amp, rad))
|
||||||
rng.uniform(0.4, 2.0),
|
|
||||||
)
|
valleys = []
|
||||||
for _ in range(rng.randint(8, 20))
|
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 {
|
return {
|
||||||
"seed": int(seed),
|
"seed": int(seed),
|
||||||
"sizeX": size_x,
|
"sizeX": size_x,
|
||||||
@@ -127,6 +188,17 @@ def build_seafloor_params(
|
|||||||
"hills": hills,
|
"hills": hills,
|
||||||
"valleys": valleys,
|
"valleys": valleys,
|
||||||
"bumps": bumps,
|
"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(
|
def prepare_mle_scene(
|
||||||
*,
|
*,
|
||||||
seed: int = 42,
|
seed: int = 42,
|
||||||
size_x: float = 40.0,
|
size_x: float | None = None,
|
||||||
size_y: float = 60.0,
|
size_y: float | None = None,
|
||||||
res_x: int = 80,
|
res_x: int = 80,
|
||||||
res_y: int = 120,
|
res_y: int = 120,
|
||||||
output_dir: str | Path = "mle_runs",
|
output_dir: str | Path = "mle_runs",
|
||||||
settings: dict[str, Any] | None = None,
|
settings: dict[str, Any] | None = None,
|
||||||
|
relief_scale_pct: float = 20.0,
|
||||||
|
corridor: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Generate seafloor for simulation; create run folder with empty survey OBJ."""
|
"""Generate seafloor for simulation; create run folder with empty survey OBJ."""
|
||||||
base = resolve_mle_dir(output_dir)
|
base = resolve_mle_dir(output_dir)
|
||||||
@@ -269,7 +343,33 @@ def prepare_mle_scene(
|
|||||||
n += 1
|
n += 1
|
||||||
run_dir.mkdir(parents=True, exist_ok=False)
|
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)
|
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.
|
# Survey OBJ starts empty and is filled from multibeam hits during motion.
|
||||||
obj_path = run_dir / "seafloor.obj"
|
obj_path = run_dir / "seafloor.obj"
|
||||||
|
|||||||
@@ -297,11 +297,19 @@ export const api = {
|
|||||||
objectScaleIsMax = false,
|
objectScaleIsMax = false,
|
||||||
beamCount = 45,
|
beamCount = 45,
|
||||||
lengthCount = 45,
|
lengthCount = 45,
|
||||||
modelFile,
|
absentPct = 30,
|
||||||
|
nearlyHiddenPct = 20,
|
||||||
|
partialPct = 40,
|
||||||
|
visiblePct = 40,
|
||||||
|
modelPreset = null,
|
||||||
|
modelFile = null,
|
||||||
onProgress,
|
onProgress,
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
if (!modelFile) {
|
const preset = modelPreset ? String(modelPreset).trim() : "";
|
||||||
return Promise.reject(new Error("Выберите файл модели .obj"));
|
if (!preset && !modelFile) {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error("Выберите предустановку объекта или загрузите файл модели .obj"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("count", String(count));
|
formData.append("count", String(count));
|
||||||
@@ -311,7 +319,15 @@ export const api = {
|
|||||||
formData.append("objectScaleIsMax", objectScaleIsMax ? "true" : "false");
|
formData.append("objectScaleIsMax", objectScaleIsMax ? "true" : "false");
|
||||||
formData.append("beamCount", String(beamCount ?? 45));
|
formData.append("beamCount", String(beamCount ?? 45));
|
||||||
formData.append("lengthCount", String(lengthCount ?? 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", {
|
const response = await fetch("/api/dataset/generate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -397,17 +413,39 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
mlePrepare: ({
|
mlePrepare: ({
|
||||||
seed = 42,
|
seed = 42,
|
||||||
sizeX = 40,
|
sizeX = null,
|
||||||
sizeY = 60,
|
sizeY = null,
|
||||||
resX = 80,
|
resX = 80,
|
||||||
resY = 120,
|
resY = 120,
|
||||||
outputDir = "mle_runs",
|
outputDir = "mle_runs",
|
||||||
|
reliefScalePct = 20,
|
||||||
|
auvX = null,
|
||||||
|
auvY = null,
|
||||||
|
auvHeadingDeg = null,
|
||||||
|
surveyLength = null,
|
||||||
|
auvDepth = null,
|
||||||
|
swathAngleDeg = null,
|
||||||
settings = null,
|
settings = null,
|
||||||
} = {}) =>
|
} = {}) =>
|
||||||
request("/api/mle/prepare", {
|
request("/api/mle/prepare", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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" } = {}) =>
|
mleSaveSurface: ({ outputDir, vertices, faces, filename = "seafloor.obj" } = {}) =>
|
||||||
request("/api/mle/save-surface", {
|
request("/api/mle/save-surface", {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
// —— main scene ——
|
// —— main scene ——
|
||||||
const SCENE_BG = 0x1a3d38;
|
const SCENE_BG = 0x1a3d38;
|
||||||
const SEAFLOOR_COLOR = 0x3f7a62;
|
const SEAFLOOR_COLOR = 0x3f7a62;
|
||||||
|
const RELIEF_COLOR = 0xffc107; // bright gold for path unevenness
|
||||||
const RAY_COLOR = 0xffb020;
|
const RAY_COLOR = 0xffb020;
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
scene.background = new THREE.Color(SCENE_BG);
|
scene.background = new THREE.Color(SCENE_BG);
|
||||||
@@ -115,6 +116,17 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
let animationId = 0;
|
let animationId = 0;
|
||||||
let seafloorMesh = null; // unused for draw; height-field used for hits
|
let seafloorMesh = null; // unused for draw; height-field used for hits
|
||||||
let seafloorGrid = null;
|
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 auvPivot = null;
|
||||||
let objectPivot = null;
|
let objectPivot = null;
|
||||||
let rayLines = 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(seafloorMesh);
|
||||||
clearObject(seafloorGrid);
|
clearObject(seafloorGrid);
|
||||||
|
clearPathRelief();
|
||||||
seafloorMesh = null;
|
seafloorMesh = null;
|
||||||
seafloorGrid = null;
|
seafloorGrid = null;
|
||||||
meshInfo = null;
|
meshInfo = null;
|
||||||
if (!payload?.mesh?.heights?.length && !payload?.mesh?.vertices?.length) return;
|
if (payload) lastSeafloorPayload = payload;
|
||||||
const { heights, resX, resY } = payload.mesh;
|
if (corridorOpts) setSurveyCorridor(corridorOpts);
|
||||||
const params = payload.params || {};
|
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 sizeX = Number(params.sizeX) || 40;
|
||||||
const sizeY = Number(params.sizeY) || 60;
|
const sizeY = Number(params.sizeY) || 60;
|
||||||
meshInfo = {
|
meshInfo = {
|
||||||
@@ -208,13 +462,13 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
sizeX,
|
sizeX,
|
||||||
sizeY,
|
sizeY,
|
||||||
baseZ: params.baseZ,
|
baseZ: params.baseZ,
|
||||||
|
params,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lightweight terrain grid (LineSegments) — infinite skirt, low vertex count
|
// Lightweight terrain grid (LineSegments) — infinite skirt, low vertex count
|
||||||
const pad = Math.max(sizeX, sizeY, detectionRangeM || 400) * 2.5;
|
const pad = Math.max(sizeX, sizeY, detectionRangeM || 400) * 2.5;
|
||||||
const extSizeX = sizeX + pad * 2;
|
const extSizeX = sizeX + pad * 2;
|
||||||
const extSizeY = sizeY + 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 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 nY = Math.min(64, Math.max(24, Math.round(extSizeY / Math.max(sizeY / 16, 4)) + 1));
|
||||||
const halfX = extSizeX * 0.5;
|
const halfX = extSizeX * 0.5;
|
||||||
@@ -231,7 +485,6 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
pts[o + 2] = z;
|
pts[o + 2] = z;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Horizontal + vertical polylines as segments
|
|
||||||
const segCount = nY * (nX - 1) + nX * (nY - 1);
|
const segCount = nY * (nX - 1) + nX * (nY - 1);
|
||||||
const linePos = new Float32Array(segCount * 2 * 3);
|
const linePos = new Float32Array(segCount * 2 * 3);
|
||||||
let w = 0;
|
let w = 0;
|
||||||
@@ -258,7 +511,7 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
new THREE.LineBasicMaterial({
|
new THREE.LineBasicMaterial({
|
||||||
color: SEAFLOOR_COLOR,
|
color: SEAFLOOR_COLOR,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.9,
|
opacity: 0.85,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
scene.add(seafloorGrid);
|
scene.add(seafloorGrid);
|
||||||
@@ -557,6 +810,29 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
return normalizeModel(g, Math.max(0.05, Number(sizeM) || 2));
|
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) {
|
async function setAuvModel(file, sizeM = 10, x = 0, y = 0, depth = 2.5, headingDeg = 0) {
|
||||||
clearObject(auvPivot);
|
clearObject(auvPivot);
|
||||||
auvPivot = new THREE.Group();
|
auvPivot = new THREE.Group();
|
||||||
@@ -573,17 +849,43 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
placeAuv(x, y, depth, headingDeg);
|
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);
|
clearObject(objectPivot);
|
||||||
objectPivot = new THREE.Group();
|
objectPivot = new THREE.Group();
|
||||||
const targetSize = Math.max(0.05, Number(sizeM) || 2);
|
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;
|
let model = null;
|
||||||
try {
|
if (kind === "pipe") {
|
||||||
model = await loadObjFile(file, targetSize);
|
model = makePipelineObject(targetSize, lengthM);
|
||||||
} catch {
|
} else {
|
||||||
model = null;
|
try {
|
||||||
|
model = await loadObjFile(file, targetSize);
|
||||||
|
} catch {
|
||||||
|
model = null;
|
||||||
|
}
|
||||||
|
if (!model) model = makeFallbackObject(targetSize);
|
||||||
}
|
}
|
||||||
if (!model) model = makeFallbackObject(targetSize);
|
|
||||||
objectPivot.add(model);
|
objectPivot.add(model);
|
||||||
scene.add(objectPivot);
|
scene.add(objectPivot);
|
||||||
placeObject(x, y, zOffset, rotXDeg, rotYDeg, rotZDeg);
|
placeObject(x, y, zOffset, rotXDeg, rotYDeg, rotZDeg);
|
||||||
@@ -718,6 +1020,8 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
appendSurveyStrip(hits);
|
appendSurveyStrip(hits);
|
||||||
void persistSurveySurface(false);
|
void persistSurveySurface(false);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
buildPathReliefPreview();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,8 +1126,6 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
|
|
||||||
async function applyScene({ seafloorPayload, auvFile, objectFile, params }) {
|
async function applyScene({ seafloorPayload, auvFile, objectFile, params }) {
|
||||||
runOutputDir = seafloorPayload?.outputDir || null;
|
runOutputDir = seafloorPayload?.outputDir || null;
|
||||||
buildSeafloor(seafloorPayload);
|
|
||||||
resetSurveySurface();
|
|
||||||
beamCount = params.beamCount ?? 45;
|
beamCount = params.beamCount ?? 45;
|
||||||
swathAngleDeg = params.swathAngleDeg ?? 90;
|
swathAngleDeg = params.swathAngleDeg ?? 90;
|
||||||
detectionRangeM = Math.max(1, Number(params.detectionRangeM) || DETECTION_RANGE_DEFAULT);
|
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);
|
speed = Math.max(0.05, Number(params.speed) || 1.5);
|
||||||
surveyLength = Math.max(1, Number(params.surveyLength) || 40);
|
surveyLength = Math.max(1, Number(params.surveyLength) || 40);
|
||||||
auvDepth = Math.max(0.3, Number(params.auvDepth) || 2.5);
|
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(
|
await setAuvModel(
|
||||||
auvFile,
|
auvFile,
|
||||||
params.auvSizeM,
|
params.auvSizeM,
|
||||||
@@ -848,6 +1159,10 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
params.objectRotXDeg,
|
params.objectRotXDeg,
|
||||||
params.objectRotYDeg,
|
params.objectRotYDeg,
|
||||||
params.objectRotZDeg ?? params.objectYawDeg,
|
params.objectRotZDeg ?? params.objectYawDeg,
|
||||||
|
{
|
||||||
|
kind: params.objectKind,
|
||||||
|
lengthM: params.objectLengthM,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
trailPoints.length = 0;
|
trailPoints.length = 0;
|
||||||
traveled = 0;
|
traveled = 0;
|
||||||
@@ -858,6 +1173,7 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
function start() {
|
function start() {
|
||||||
if (!auvPivot || !meshInfo) return false;
|
if (!auvPivot || !meshInfo) return false;
|
||||||
resetSurveySurface();
|
resetSurveySurface();
|
||||||
|
clearPathRelief();
|
||||||
running = true;
|
running = true;
|
||||||
lastTs = 0;
|
lastTs = 0;
|
||||||
traveled = 0;
|
traveled = 0;
|
||||||
@@ -869,6 +1185,7 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
function stop() {
|
function stop() {
|
||||||
running = false;
|
running = false;
|
||||||
void persistSurveySurface(true);
|
void persistSurveySurface(true);
|
||||||
|
buildPathReliefPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRunning() {
|
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.surveyLength != null) surveyLength = Math.max(1, Number(params.surveyLength) || 40);
|
||||||
if (params.auvDepth != null) auvDepth = Math.max(0.3, Number(params.auvDepth) || 2.5);
|
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;
|
let moved = false;
|
||||||
if (!running && auvPivot && (params.auvX != null || params.auvY != null || params.auvDepth != null || params.auvHeadingDeg != null)) {
|
if (!running && auvPivot && (params.auvX != null || params.auvY != null || params.auvDepth != null || params.auvHeadingDeg != null)) {
|
||||||
placeAuv(
|
placeAuv(
|
||||||
@@ -927,6 +1265,43 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
if (!running) updateRays(false);
|
if (!running) updateRays(false);
|
||||||
moved = true;
|
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();
|
if (moved || params.fitCamera) fitCamera();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,6 +1350,7 @@ export function useMleSimulator(containerRef, surveyRef) {
|
|||||||
surveyControls?.dispose();
|
surveyControls?.dispose();
|
||||||
clearObject(seafloorMesh);
|
clearObject(seafloorMesh);
|
||||||
clearObject(seafloorGrid);
|
clearObject(seafloorGrid);
|
||||||
|
clearPathRelief();
|
||||||
clearObject(auvPivot);
|
clearObject(auvPivot);
|
||||||
clearObject(objectPivot);
|
clearObject(objectPivot);
|
||||||
clearObject(rayLines);
|
clearObject(rayLines);
|
||||||
|
|||||||
@@ -1,21 +1,61 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { api } from "@/api/client";
|
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", {
|
export const useDatasetStore = defineStore("dataset", {
|
||||||
state: () => ({
|
state: () => {
|
||||||
|
const saved = loadPersistedDataset();
|
||||||
|
return {
|
||||||
busy: false,
|
busy: false,
|
||||||
previewBusy: false,
|
previewBusy: false,
|
||||||
statusText: "Выберите .obj модель и нажмите «Сгенерировать».",
|
statusText: "Выберите модель объекта и нажмите «Сгенерировать».",
|
||||||
count: 5,
|
count: saved.count ?? 5,
|
||||||
seed: 42,
|
seed: saved.seed ?? 42,
|
||||||
outputDir: "sonar_dataset",
|
outputDir: saved.outputDir ?? "sonar_dataset",
|
||||||
resolvedOutputDir: null,
|
resolvedOutputDir: null,
|
||||||
|
modelSource: saved.modelSource ?? "preset",
|
||||||
|
modelPreset: saved.modelPreset ?? "pipe",
|
||||||
modelFile: null,
|
modelFile: null,
|
||||||
modelFileName: "",
|
modelFileName: saved.modelFileName ?? "",
|
||||||
objectScale: 1,
|
objectScale: saved.objectScale ?? 1,
|
||||||
objectScaleIsMax: false,
|
objectScaleIsMax: !!saved.objectScaleIsMax,
|
||||||
beamCount: 45,
|
beamCount: saved.beamCount ?? 45,
|
||||||
lengthCount: 45,
|
lengthCount: saved.lengthCount ?? 45,
|
||||||
|
absentPct: saved.absentPct ?? 30,
|
||||||
|
nearlyHiddenPct: saved.nearlyHiddenPct ?? 20,
|
||||||
|
partialPct: saved.partialPct ?? 40,
|
||||||
|
visiblePct: saved.visiblePct ?? 40,
|
||||||
generateProgress: 0,
|
generateProgress: 0,
|
||||||
browseBusy: false,
|
browseBusy: false,
|
||||||
availableRuns: [],
|
availableRuns: [],
|
||||||
@@ -29,7 +69,11 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
classLabels: { 0: "background", 1: "object" },
|
classLabels: { 0: "background", 1: "object" },
|
||||||
classCounts: {},
|
classCounts: {},
|
||||||
logLines: [],
|
logLines: [],
|
||||||
}),
|
alertVisible: false,
|
||||||
|
alertMessage: "",
|
||||||
|
_persistTimer: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
getters: {
|
getters: {
|
||||||
stats(state) {
|
stats(state) {
|
||||||
return state.lastResult?.stats || null;
|
return state.lastResult?.stats || null;
|
||||||
@@ -48,6 +92,19 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
canGenerate(state) {
|
canGenerate(state) {
|
||||||
return !state.busy;
|
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) {
|
classOptions(state) {
|
||||||
const labels = state.classLabels || { 0: "background", 1: "object" };
|
const labels = state.classLabels || { 0: "background", 1: "object" };
|
||||||
const counts = state.classCounts || {};
|
const counts = state.classCounts || {};
|
||||||
@@ -65,12 +122,35 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
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) {
|
pushLog(line) {
|
||||||
this.logLines.push(String(line));
|
this.logLines.push(String(line));
|
||||||
if (this.logLines.length > 200) {
|
if (this.logLines.length > 200) {
|
||||||
this.logLines = this.logLines.slice(-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) {
|
setHighlightClass(value) {
|
||||||
if (value === null || value === undefined || value === "" || value === "all") {
|
if (value === null || value === undefined || value === "" || value === "all") {
|
||||||
this.highlightClass = null;
|
this.highlightClass = null;
|
||||||
@@ -88,6 +168,10 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
const objectScaleIsMax = settings.objectScaleIsMax ?? run?.objectScaleIsMax;
|
const objectScaleIsMax = settings.objectScaleIsMax ?? run?.objectScaleIsMax;
|
||||||
const beamCount = settings.beamCount ?? run?.beamCount;
|
const beamCount = settings.beamCount ?? run?.beamCount;
|
||||||
const lengthCount = settings.lengthCount ?? run?.lengthCount;
|
const lengthCount = settings.lengthCount ?? run?.lengthCount;
|
||||||
|
const absentPct = settings.absentPct;
|
||||||
|
const nearlyHiddenPct = settings.nearlyHiddenPct;
|
||||||
|
const partialPct = settings.partialPct;
|
||||||
|
const visiblePct = settings.visiblePct;
|
||||||
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (objectName) parts.push(`model=${objectName}`);
|
if (objectName) parts.push(`model=${objectName}`);
|
||||||
@@ -102,6 +186,16 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
: `scale=${objectScale}`,
|
: `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 (outputDir) parts.push(`dir=${outputDir}`);
|
||||||
if (run?.objectVertexCount != null) {
|
if (run?.objectVertexCount != null) {
|
||||||
parts.push(`vertices=${run.objectVertexCount}`);
|
parts.push(`vertices=${run.objectVertexCount}`);
|
||||||
@@ -134,7 +228,7 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
if (!file) {
|
if (!file) {
|
||||||
this.modelFile = null;
|
this.modelFile = null;
|
||||||
this.modelFileName = "";
|
this.modelFileName = "";
|
||||||
this.statusText = "Выберите .obj модель и нажмите «Сгенерировать».";
|
this.statusText = "Выберите модель объекта и нажмите «Сгенерировать».";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const name = String(file.name || "");
|
const name = String(file.name || "");
|
||||||
@@ -144,22 +238,52 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
this.statusText = "Нужен файл формата .obj";
|
this.statusText = "Нужен файл формата .obj";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.modelSource = "file";
|
||||||
this.modelFile = file;
|
this.modelFile = file;
|
||||||
this.modelFileName = name;
|
this.modelFileName = name;
|
||||||
this.statusText = `Модель: ${name}`;
|
this.statusText = `Модель: ${name}`;
|
||||||
this.pushLog(`Выбрана модель: ${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() {
|
async generate() {
|
||||||
if (this.busy) return;
|
if (this.busy) return;
|
||||||
if (!this.modelFile) {
|
if (!this.hasObjectModel) {
|
||||||
this.statusText = "Сначала выберите файл модели .obj";
|
const msg =
|
||||||
|
this.modelSource === "file"
|
||||||
|
? "Файл объекта не выбран. Выберите модель .obj перед генерацией."
|
||||||
|
: "Модель объекта не выбрана.";
|
||||||
|
this.statusText = msg;
|
||||||
|
this.pushLog(msg);
|
||||||
|
this.showAlert(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
this.generateProgress = 0;
|
this.generateProgress = 0;
|
||||||
this.statusText = "Генерация датасета…";
|
this.statusText = "Генерация датасета…";
|
||||||
|
const modelLabel = this.selectedModelLabel || this.modelFileName || this.modelPreset;
|
||||||
this.pushLog(
|
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 {
|
try {
|
||||||
const result = await api.datasetGenerate({
|
const result = await api.datasetGenerate({
|
||||||
@@ -170,7 +294,12 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
objectScaleIsMax: !!this.objectScaleIsMax,
|
objectScaleIsMax: !!this.objectScaleIsMax,
|
||||||
beamCount: Number(this.beamCount) || 45,
|
beamCount: Number(this.beamCount) || 45,
|
||||||
lengthCount: Number(this.lengthCount) || 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) => {
|
onProgress: (event) => {
|
||||||
if (event.type === "start") {
|
if (event.type === "start") {
|
||||||
this.generateProgress = 0;
|
this.generateProgress = 0;
|
||||||
@@ -208,7 +337,7 @@ export const useDatasetStore = defineStore("dataset", {
|
|||||||
? `scale=1…${result.objectScale ?? this.objectScale} (макс.)`
|
? `scale=1…${result.objectScale ?? this.objectScale} (макс.)`
|
||||||
: `scale=${result.objectScale ?? this.objectScale}`;
|
: `scale=${result.objectScale ?? this.objectScale}`;
|
||||||
this.pushLog(
|
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(
|
this.pushLog(
|
||||||
`Записано ${result.count} сцен в ${result.runName || result.outputDir}. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
|
`Записано ${result.count} сцен в ${result.runName || result.outputDir}. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { api, exportFilename, parseCloudPoints } from "@/api/client";
|
import { api, exportFilename, parseCloudPoints } from "@/api/client";
|
||||||
|
import { createDebouncedSaver, loadUserSettings, saveUserSettings } from "@/utils/userSettings";
|
||||||
|
|
||||||
let layerSeq = 1;
|
let layerSeq = 1;
|
||||||
const MAX_HISTORY = 80;
|
const MAX_HISTORY = 80;
|
||||||
|
const STORAGE_KEY = "dottosurface.generator.settings.v1";
|
||||||
const IMPORT_COLORS = {
|
const IMPORT_COLORS = {
|
||||||
obj: "#f472b6",
|
obj: "#f472b6",
|
||||||
ply: "#38bdf8",
|
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", {
|
export const useGeneratorStore = defineStore("generator", {
|
||||||
state: () => ({
|
state: () => {
|
||||||
|
const saved = loadPersistedGenerator();
|
||||||
|
return {
|
||||||
busy: false,
|
busy: false,
|
||||||
statusText: "Добавьте объект или поверхность.",
|
statusText: "Добавьте объект или поверхность.",
|
||||||
catalog: [],
|
catalog: [],
|
||||||
colors: {},
|
colors: {},
|
||||||
layers: [],
|
layers: [],
|
||||||
selectedLayerId: null,
|
selectedLayerId: saved.selectedLayerId,
|
||||||
interactionMode: "orbit",
|
interactionMode: saved.interactionMode,
|
||||||
translateStep: 0.05,
|
translateStep: saved.translateStep,
|
||||||
rotateStepDeg: 5,
|
rotateStepDeg: saved.rotateStepDeg,
|
||||||
exportFormat: "xyz",
|
exportFormat: saved.exportFormat,
|
||||||
viewerRevision: 0,
|
viewerRevision: 0,
|
||||||
history: [makeSnapshot("Начало", [], null)],
|
history: [makeSnapshot("Начало", [], null)],
|
||||||
historyIndex: 0,
|
historyIndex: 0,
|
||||||
_restoring: false,
|
_restoring: false,
|
||||||
}),
|
_pendingRecipes: saved.layerRecipes,
|
||||||
|
};
|
||||||
|
},
|
||||||
getters: {
|
getters: {
|
||||||
selectedLayer(state) {
|
selectedLayer(state) {
|
||||||
return state.layers.find((layer) => layer.id === state.selectedLayerId) || null;
|
return state.layers.find((layer) => layer.id === state.selectedLayerId) || null;
|
||||||
@@ -111,11 +144,75 @@ export const useGeneratorStore = defineStore("generator", {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
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() {
|
async bootstrap() {
|
||||||
const payload = await api.generatorCatalog();
|
const payload = await api.generatorCatalog();
|
||||||
this.catalog = payload.layers || [];
|
this.catalog = payload.layers || [];
|
||||||
this.colors = payload.colors || {};
|
this.colors = payload.colors || {};
|
||||||
this.statusText = "Каталог загружен. Добавьте слой.";
|
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() {
|
bumpViewer() {
|
||||||
this.viewerRevision += 1;
|
this.viewerRevision += 1;
|
||||||
@@ -145,6 +242,7 @@ export const useGeneratorStore = defineStore("generator", {
|
|||||||
this.history.splice(0, this.history.length - MAX_HISTORY);
|
this.history.splice(0, this.history.length - MAX_HISTORY);
|
||||||
}
|
}
|
||||||
this.historyIndex = this.history.length - 1;
|
this.historyIndex = this.history.length - 1;
|
||||||
|
this.schedulePersist();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("recordHistory failed", error);
|
console.error("recordHistory failed", error);
|
||||||
this.statusText = `История не записана: ${error.message}`;
|
this.statusText = `История не записана: ${error.message}`;
|
||||||
@@ -181,6 +279,7 @@ export const useGeneratorStore = defineStore("generator", {
|
|||||||
},
|
},
|
||||||
selectLayer(id) {
|
selectLayer(id) {
|
||||||
this.selectedLayerId = id;
|
this.selectedLayerId = id;
|
||||||
|
this.schedulePersist();
|
||||||
},
|
},
|
||||||
setInteractionMode(mode) {
|
setInteractionMode(mode) {
|
||||||
if (mode === "translate" || mode === "rotate") {
|
if (mode === "translate" || mode === "rotate") {
|
||||||
@@ -188,18 +287,22 @@ export const useGeneratorStore = defineStore("generator", {
|
|||||||
} else {
|
} else {
|
||||||
this.interactionMode = "orbit";
|
this.interactionMode = "orbit";
|
||||||
}
|
}
|
||||||
|
this.schedulePersist();
|
||||||
},
|
},
|
||||||
setTranslateStep(step) {
|
setTranslateStep(step) {
|
||||||
const value = Number(step);
|
const value = Number(step);
|
||||||
this.translateStep = Number.isFinite(value) && value > 0 ? value : 0.05;
|
this.translateStep = Number.isFinite(value) && value > 0 ? value : 0.05;
|
||||||
|
this.schedulePersist();
|
||||||
},
|
},
|
||||||
setRotateStepDeg(step) {
|
setRotateStepDeg(step) {
|
||||||
const value = Number(step);
|
const value = Number(step);
|
||||||
this.rotateStepDeg = Number.isFinite(value) && value > 0 ? value : 5;
|
this.rotateStepDeg = Number.isFinite(value) && value > 0 ? value : 5;
|
||||||
|
this.schedulePersist();
|
||||||
},
|
},
|
||||||
setExportFormat(format) {
|
setExportFormat(format) {
|
||||||
const allowed = new Set(["xyz", "ply", "obj", "npy"]);
|
const allowed = new Set(["xyz", "ply", "obj", "npy"]);
|
||||||
this.exportFormat = allowed.has(format) ? format : "xyz";
|
this.exportFormat = allowed.has(format) ? format : "xyz";
|
||||||
|
this.schedulePersist();
|
||||||
},
|
},
|
||||||
updateSelectedParams(partial) {
|
updateSelectedParams(partial) {
|
||||||
const layer = this.selectedLayer;
|
const layer = this.selectedLayer;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const DEFAULTS = {
|
|||||||
objectY: -20,
|
objectY: -20,
|
||||||
objectZ: 0,
|
objectZ: 0,
|
||||||
objectSizeM: 2,
|
objectSizeM: 2,
|
||||||
|
objectLengthM: 20,
|
||||||
|
objectKind: "pipe", // "pipe" | "file"
|
||||||
objectRotXDeg: 0,
|
objectRotXDeg: 0,
|
||||||
objectRotYDeg: 0,
|
objectRotYDeg: 0,
|
||||||
objectRotZDeg: 0,
|
objectRotZDeg: 0,
|
||||||
@@ -22,6 +24,7 @@ const DEFAULTS = {
|
|||||||
speed: 1.5,
|
speed: 1.5,
|
||||||
surveyLength: 40,
|
surveyLength: 40,
|
||||||
seed: 42,
|
seed: 42,
|
||||||
|
reliefScalePct: 20,
|
||||||
sizeX: 40,
|
sizeX: 40,
|
||||||
sizeY: 60,
|
sizeY: 60,
|
||||||
outputDir: "mle_runs",
|
outputDir: "mle_runs",
|
||||||
@@ -57,6 +60,8 @@ function coerceSettings(data) {
|
|||||||
objectY: data.object.y ?? data.objectY,
|
objectY: data.object.y ?? data.objectY,
|
||||||
objectZ: data.object.z ?? data.objectZ,
|
objectZ: data.object.z ?? data.objectZ,
|
||||||
objectSizeM: data.object.sizeM ?? data.objectSizeM,
|
objectSizeM: data.object.sizeM ?? data.objectSizeM,
|
||||||
|
objectLengthM: data.object.lengthM ?? data.objectLengthM,
|
||||||
|
objectKind: data.object.kind ?? data.objectKind,
|
||||||
objectRotXDeg: data.object.rotXDeg ?? data.objectRotXDeg,
|
objectRotXDeg: data.object.rotXDeg ?? data.objectRotXDeg,
|
||||||
objectRotYDeg: data.object.rotYDeg ?? data.objectRotYDeg,
|
objectRotYDeg: data.object.rotYDeg ?? data.objectRotYDeg,
|
||||||
objectRotZDeg: data.object.rotZDeg ?? data.object.yawDeg ?? data.objectRotZDeg,
|
objectRotZDeg: data.object.rotZDeg ?? data.object.yawDeg ?? data.objectRotZDeg,
|
||||||
@@ -81,12 +86,17 @@ function coerceSettings(data) {
|
|||||||
data = {
|
data = {
|
||||||
...data,
|
...data,
|
||||||
seed: data.seafloor.seed ?? data.seed,
|
seed: data.seafloor.seed ?? data.seed,
|
||||||
|
reliefScalePct: data.seafloor.reliefScalePct ?? data.reliefScalePct,
|
||||||
sizeX: data.seafloor.sizeX ?? data.sizeX,
|
sizeX: data.seafloor.sizeX ?? data.sizeX,
|
||||||
sizeY: data.seafloor.sizeY ?? data.sizeY,
|
sizeY: data.seafloor.sizeY ?? data.sizeY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
for (const key of Object.keys(DEFAULTS)) {
|
for (const key of Object.keys(DEFAULTS)) {
|
||||||
if (data[key] == null) continue;
|
if (data[key] == null) continue;
|
||||||
|
if (key === "objectKind") {
|
||||||
|
out[key] = String(data[key]) === "pipe" ? "pipe" : "file";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (typeof DEFAULTS[key] === "number") {
|
if (typeof DEFAULTS[key] === "number") {
|
||||||
const n = Number(data[key]);
|
const n = Number(data[key]);
|
||||||
if (Number.isFinite(n)) out[key] = n;
|
if (Number.isFinite(n)) out[key] = n;
|
||||||
@@ -131,6 +141,8 @@ export const useMleStore = defineStore("mle", {
|
|||||||
objectY: saved.objectY ?? DEFAULTS.objectY,
|
objectY: saved.objectY ?? DEFAULTS.objectY,
|
||||||
objectZ: saved.objectZ ?? DEFAULTS.objectZ,
|
objectZ: saved.objectZ ?? DEFAULTS.objectZ,
|
||||||
objectSizeM: saved.objectSizeM ?? DEFAULTS.objectSizeM,
|
objectSizeM: saved.objectSizeM ?? DEFAULTS.objectSizeM,
|
||||||
|
objectLengthM: saved.objectLengthM ?? DEFAULTS.objectLengthM,
|
||||||
|
objectKind: saved.objectKind ?? DEFAULTS.objectKind,
|
||||||
objectRotXDeg: saved.objectRotXDeg ?? DEFAULTS.objectRotXDeg,
|
objectRotXDeg: saved.objectRotXDeg ?? DEFAULTS.objectRotXDeg,
|
||||||
objectRotYDeg: saved.objectRotYDeg ?? DEFAULTS.objectRotYDeg,
|
objectRotYDeg: saved.objectRotYDeg ?? DEFAULTS.objectRotYDeg,
|
||||||
objectRotZDeg: saved.objectRotZDeg ?? DEFAULTS.objectRotZDeg,
|
objectRotZDeg: saved.objectRotZDeg ?? DEFAULTS.objectRotZDeg,
|
||||||
@@ -142,6 +154,7 @@ export const useMleStore = defineStore("mle", {
|
|||||||
surveyLength: saved.surveyLength ?? DEFAULTS.surveyLength,
|
surveyLength: saved.surveyLength ?? DEFAULTS.surveyLength,
|
||||||
|
|
||||||
seed: saved.seed ?? DEFAULTS.seed,
|
seed: saved.seed ?? DEFAULTS.seed,
|
||||||
|
reliefScalePct: saved.reliefScalePct ?? DEFAULTS.reliefScalePct,
|
||||||
sizeX: saved.sizeX ?? DEFAULTS.sizeX,
|
sizeX: saved.sizeX ?? DEFAULTS.sizeX,
|
||||||
sizeY: saved.sizeY ?? DEFAULTS.sizeY,
|
sizeY: saved.sizeY ?? DEFAULTS.sizeY,
|
||||||
outputDir: saved.outputDir ?? DEFAULTS.outputDir,
|
outputDir: saved.outputDir ?? DEFAULTS.outputDir,
|
||||||
@@ -246,11 +259,20 @@ export const useMleStore = defineStore("mle", {
|
|||||||
this.statusText = "Объект: нужен файл .obj";
|
this.statusText = "Объект: нужен файл .obj";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.objectKind = "file";
|
||||||
this.objectFile = file;
|
this.objectFile = file;
|
||||||
this.objectFileName = name;
|
this.objectFileName = name;
|
||||||
this.pushLog(`Объект: ${name}`);
|
this.pushLog(`Объект: ${name}`);
|
||||||
this.persistSettings();
|
this.persistSettings();
|
||||||
},
|
},
|
||||||
|
setObjectKind(kind) {
|
||||||
|
const next = kind === "pipe" ? "pipe" : "file";
|
||||||
|
this.objectKind = next;
|
||||||
|
if (next === "pipe") {
|
||||||
|
this.pushLog("Объект: трубопровод (предустановка)");
|
||||||
|
}
|
||||||
|
this.persistSettings();
|
||||||
|
},
|
||||||
placeObjectAlongTrack() {
|
placeObjectAlongTrack() {
|
||||||
const heading = ((Number(this.auvHeadingDeg) || 0) * Math.PI) / 180;
|
const heading = ((Number(this.auvHeadingDeg) || 0) * Math.PI) / 180;
|
||||||
const ahead = Math.min(18, Math.max(6, Number(this.surveyLength) * 0.4 || 12));
|
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,
|
y: this.objectY,
|
||||||
z: this.objectZ,
|
z: this.objectZ,
|
||||||
sizeM: this.objectSizeM,
|
sizeM: this.objectSizeM,
|
||||||
|
lengthM: this.objectLengthM,
|
||||||
|
kind: this.objectKind,
|
||||||
rotXDeg: this.objectRotXDeg,
|
rotXDeg: this.objectRotXDeg,
|
||||||
rotYDeg: this.objectRotYDeg,
|
rotYDeg: this.objectRotYDeg,
|
||||||
rotZDeg: this.objectRotZDeg,
|
rotZDeg: this.objectRotZDeg,
|
||||||
@@ -289,6 +313,7 @@ export const useMleStore = defineStore("mle", {
|
|||||||
},
|
},
|
||||||
seafloor: {
|
seafloor: {
|
||||||
seed: this.seed,
|
seed: this.seed,
|
||||||
|
reliefScalePct: this.reliefScalePct,
|
||||||
sizeX: this.sizeX,
|
sizeX: this.sizeX,
|
||||||
sizeY: this.sizeY,
|
sizeY: this.sizeY,
|
||||||
},
|
},
|
||||||
@@ -296,23 +321,34 @@ export const useMleStore = defineStore("mle", {
|
|||||||
},
|
},
|
||||||
async prepare() {
|
async prepare() {
|
||||||
if (this.busy || this.running) return;
|
if (this.busy || this.running) return;
|
||||||
|
// Новый случайный рельеф при каждой подготовке.
|
||||||
|
this.seed = Math.floor(Math.random() * 1_000_000);
|
||||||
this.placeObjectAlongTrack();
|
this.placeObjectAlongTrack();
|
||||||
this.persistSettings();
|
this.persistSettings();
|
||||||
this.busy = true;
|
this.busy = true;
|
||||||
this.statusText = "Генерация рельефа дна…";
|
this.statusText = "Генерация рельефа дна…";
|
||||||
|
const reliefPct = Math.max(1, Math.min(100, Number(this.reliefScalePct) || 20));
|
||||||
|
this.reliefScalePct = reliefPct;
|
||||||
this.pushLog(
|
this.pushLog(
|
||||||
`Подготовка: seed=${this.seed}, size=${this.sizeX}×${this.sizeY}, beams=${this.beamCount}`,
|
`Подготовка: seed=${this.seed}, неровности=${reliefPct}% ширины полосы, beams=${this.beamCount}`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const result = await api.mlePrepare({
|
const result = await api.mlePrepare({
|
||||||
seed: Number(this.seed) || 42,
|
seed: Number(this.seed) || 42,
|
||||||
sizeX: Number(this.sizeX) || 40,
|
reliefScalePct: reliefPct,
|
||||||
sizeY: Number(this.sizeY) || 60,
|
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,
|
resX: 80,
|
||||||
resY: 120,
|
resY: 120,
|
||||||
outputDir: String(this.outputDir || "mle_runs"),
|
outputDir: String(this.outputDir || "mle_runs"),
|
||||||
settings: this.settingsSnapshot(),
|
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.scene = result;
|
||||||
this.runName = result.runName;
|
this.runName = result.runName;
|
||||||
this.seafloorObjPath = result.seafloorObj;
|
this.seafloorObjPath = result.seafloorObj;
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
filterPaletteGroupedModel,
|
filterPaletteGroupedModel,
|
||||||
reconstructionPaletteModel,
|
reconstructionPaletteModel,
|
||||||
} from "@/catalog/pipelineUiCatalog";
|
} from "@/catalog/pipelineUiCatalog";
|
||||||
|
import { createDebouncedSaver, loadUserSettings, saveUserSettings } from "@/utils/userSettings";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "dottosurface.pipeline.settings.v1";
|
||||||
|
|
||||||
function configFromCards(stageCards) {
|
function configFromCards(stageCards) {
|
||||||
const preprocessPlugins = [];
|
const preprocessPlugins = [];
|
||||||
@@ -76,16 +79,20 @@ function pipelineFingerprint(stageCards) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const usePipelineStore = defineStore("pipeline", {
|
export const usePipelineStore = defineStore("pipeline", {
|
||||||
state: () => ({
|
state: () => {
|
||||||
|
const saved = loadUserSettings(STORAGE_KEY, {});
|
||||||
|
return {
|
||||||
busy: false,
|
busy: false,
|
||||||
statusText: "Загрузите облако или сгенерируйте демо.",
|
statusText: "Загрузите облако или сгенерируйте демо.",
|
||||||
stageCards: [],
|
stageCards: [],
|
||||||
selectedStageIndex: -1,
|
selectedStageIndex: Number.isFinite(Number(saved.selectedStageIndex))
|
||||||
surfaceVisible: true,
|
? Number(saved.selectedStageIndex)
|
||||||
demoSurfaceType: "Сфера",
|
: -1,
|
||||||
|
surfaceVisible: saved.surfaceVisible !== false,
|
||||||
|
demoSurfaceType: saved.demoSurfaceType || "Сфера",
|
||||||
demoSurfaceTypes: ["Сфера", "Тор", "Волна", "Дно реки + труба"],
|
demoSurfaceTypes: ["Сфера", "Тор", "Волна", "Дно реки + труба"],
|
||||||
wizardProfile: "general",
|
wizardProfile: saved.wizardProfile || "general",
|
||||||
wizardGoal: "balanced",
|
wizardGoal: saved.wizardGoal || "balanced",
|
||||||
presetItems: [],
|
presetItems: [],
|
||||||
metrics: {
|
metrics: {
|
||||||
inputPoints: 0,
|
inputPoints: 0,
|
||||||
@@ -111,7 +118,9 @@ export const usePipelineStore = defineStore("pipeline", {
|
|||||||
stageMetaById: {},
|
stageMetaById: {},
|
||||||
/** Fingerprint of stageCards after last successful apply/run; null = never applied. */
|
/** Fingerprint of stageCards after last successful apply/run; null = never applied. */
|
||||||
appliedFingerprint: null,
|
appliedFingerprint: null,
|
||||||
}),
|
_savedStageCards: Array.isArray(saved.stageCards) ? saved.stageCards : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
getters: {
|
getters: {
|
||||||
selectedStage(state) {
|
selectedStage(state) {
|
||||||
if (state.selectedStageIndex < 0 || state.selectedStageIndex >= state.stageCards.length) {
|
if (state.selectedStageIndex < 0 || state.selectedStageIndex >= state.stageCards.length) {
|
||||||
@@ -137,6 +146,30 @@ export const usePipelineStore = defineStore("pipeline", {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
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() {
|
async bootstrap() {
|
||||||
const [presets, defaultConfig, catalog] = await Promise.all([
|
const [presets, defaultConfig, catalog] = await Promise.all([
|
||||||
api.presets(),
|
api.presets(),
|
||||||
@@ -147,8 +180,26 @@ export const usePipelineStore = defineStore("pipeline", {
|
|||||||
(catalog.stageMeta || []).map((item) => [item.id, item]),
|
(catalog.stageMeta || []).map((item) => [item.id, item]),
|
||||||
);
|
);
|
||||||
this.presetItems = presets;
|
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();
|
await this.validateChain();
|
||||||
|
this.persistSettings();
|
||||||
},
|
},
|
||||||
async validateChain() {
|
async validateChain() {
|
||||||
const result = await api.validateConfig(this.pipelineConfig);
|
const result = await api.validateConfig(this.pipelineConfig);
|
||||||
@@ -156,9 +207,11 @@ export const usePipelineStore = defineStore("pipeline", {
|
|||||||
this.recommendation = result.recommendation;
|
this.recommendation = result.recommendation;
|
||||||
this.warningsList = result.warningsList || [];
|
this.warningsList = result.warningsList || [];
|
||||||
if (result.stageCards) this.stageCards = result.stageCards;
|
if (result.stageCards) this.stageCards = result.stageCards;
|
||||||
|
this.schedulePersist();
|
||||||
},
|
},
|
||||||
setSelectedStageIndex(index) {
|
setSelectedStageIndex(index) {
|
||||||
this.selectedStageIndex = index;
|
this.selectedStageIndex = index;
|
||||||
|
this.schedulePersist();
|
||||||
},
|
},
|
||||||
addStage(stageId) {
|
addStage(stageId) {
|
||||||
const meta = metaForStage(stageId, this.stageMetaById);
|
const meta = metaForStage(stageId, this.stageMetaById);
|
||||||
@@ -235,6 +288,7 @@ export const usePipelineStore = defineStore("pipeline", {
|
|||||||
this.stageCards = cardsFromConfig(config, this.stageMetaById);
|
this.stageCards = cardsFromConfig(config, this.stageMetaById);
|
||||||
await this.validateChain();
|
await this.validateChain();
|
||||||
this.statusText = `Пресет '${preset.title || presetId}' загружен. Нажмите 'Применить' для запуска.`;
|
this.statusText = `Пресет '${preset.title || presetId}' загружен. Нажмите 'Применить' для запуска.`;
|
||||||
|
this.schedulePersist();
|
||||||
},
|
},
|
||||||
async applyWizard() {
|
async applyWizard() {
|
||||||
const result = await api.wizard(this.wizardProfile, this.wizardGoal);
|
const result = await api.wizard(this.wizardProfile, this.wizardGoal);
|
||||||
@@ -243,12 +297,23 @@ export const usePipelineStore = defineStore("pipeline", {
|
|||||||
this.recommendation = result.recommendation;
|
this.recommendation = result.recommendation;
|
||||||
this.warningsList = result.warningsList || [];
|
this.warningsList = result.warningsList || [];
|
||||||
this.statusText = `Wizard предложил пресет '${result.title}'.`;
|
this.statusText = `Wizard предложил пресет '${result.title}'.`;
|
||||||
|
this.schedulePersist();
|
||||||
},
|
},
|
||||||
setDemoSurfaceType(value) {
|
setDemoSurfaceType(value) {
|
||||||
this.demoSurfaceType = value;
|
this.demoSurfaceType = value;
|
||||||
|
this.schedulePersist();
|
||||||
},
|
},
|
||||||
setSurfaceVisible(value) {
|
setSurfaceVisible(value) {
|
||||||
this.surfaceVisible = 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" } = {}) {
|
async runPipeline({ useDemo = false, geometryFormat = "json" } = {}) {
|
||||||
const runWithDemo = useDemo || (this.usingDemoInput && !this.currentFile);
|
const runWithDemo = useDemo || (this.usingDemoInput && !this.currentFile);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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() {
|
async function onGenerate() {
|
||||||
try {
|
try {
|
||||||
await store.generate();
|
await store.generate();
|
||||||
@@ -94,6 +115,10 @@ function onModelFileChange(event) {
|
|||||||
store.setModelFile(file);
|
store.setModelFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onModelSourceChange(event) {
|
||||||
|
store.setModelSource(event.target?.value);
|
||||||
|
}
|
||||||
|
|
||||||
function onHighlightClassChange(event) {
|
function onHighlightClassChange(event) {
|
||||||
store.setHighlightClass(event.target?.value);
|
store.setHighlightClass(event.target?.value);
|
||||||
}
|
}
|
||||||
@@ -175,21 +200,39 @@ function runOptionLabel(run) {
|
|||||||
<h2>Генератор датасета</h2>
|
<h2>Генератор датасета</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Синтетические сцены эхолота для PointNet (сегментация: фон / object).
|
Синтетические сцены эхолота для PointNet (сегментация: фон / object).
|
||||||
Целевой объект — вершины выбранной .obj модели (класс 1).
|
Целевой объект — предустановка «Трубопровод» или вершины загруженной .obj (класс 1).
|
||||||
Файлы: <code>Area_X_scene_XXXX.npy</code> + <code>.obj</code>.
|
Файлы: <code>Area_X_scene_XXXX.npy</code> + <code>.obj</code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Модель объекта (.obj)</span>
|
<span>Модель объекта</span>
|
||||||
|
<select
|
||||||
|
:value="store.modelSource"
|
||||||
|
:disabled="store.busy"
|
||||||
|
@change="onModelSourceChange"
|
||||||
|
>
|
||||||
|
<option value="preset">Трубопровод (предустановка)</option>
|
||||||
|
<option value="file">Загрузить .obj…</option>
|
||||||
|
</select>
|
||||||
|
<span class="ref-caption">
|
||||||
|
<template v-if="store.modelSource === 'preset'">
|
||||||
|
Прямой трубопровод от границы до границы. Ил (частично / почти не виден):
|
||||||
|
случайно один холм или несколько (по видимости и неровности дна).
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ store.modelFileName || "Файл не выбран" }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label v-if="store.modelSource === 'file'" class="field">
|
||||||
|
<span>Файл .obj</span>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".obj,model/obj,text/plain"
|
accept=".obj,model/obj,text/plain"
|
||||||
:disabled="store.busy"
|
:disabled="store.busy"
|
||||||
@change="onModelFileChange"
|
@change="onModelFileChange"
|
||||||
/>
|
/>
|
||||||
<span class="ref-caption">
|
|
||||||
{{ store.modelFileName || "Файл не выбран" }}
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -256,6 +299,62 @@ function runOptionLabel(run) {
|
|||||||
<span>Число сцен</span>
|
<span>Число сцен</span>
|
||||||
<input v-model.number="store.count" type="number" min="1" max="5000" step="1" />
|
<input v-model.number="store.count" type="number" min="1" max="5000" step="1" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Без объекта, %</span>
|
||||||
|
<input
|
||||||
|
v-model.number="store.absentPct"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
:disabled="store.busy"
|
||||||
|
/>
|
||||||
|
<span class="ref-caption">
|
||||||
|
Доля сцен без объекта (остальные — с объектом). По умолчанию 30 / 70.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="balance-block">
|
||||||
|
<h3 class="balance-title">Видимость объекта (среди сцен с объектом), %</h3>
|
||||||
|
<label class="field">
|
||||||
|
<span>Почти не виден</span>
|
||||||
|
<input
|
||||||
|
v-model.number="store.nearlyHiddenPct"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
:disabled="store.busy"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Частично виден</span>
|
||||||
|
<input
|
||||||
|
v-model.number="store.partialPct"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
:disabled="store.busy"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Хорошо виден</span>
|
||||||
|
<input
|
||||||
|
v-model.number="store.visiblePct"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
:disabled="store.busy"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span class="ref-caption">
|
||||||
|
Относительные доли среди сцен с объектом (нормируются). По умолчанию 20 / 40 / 40.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Seed</span>
|
<span>Seed</span>
|
||||||
<input v-model.number="store.seed" type="number" min="0" step="1" />
|
<input v-model.number="store.seed" type="number" min="0" step="1" />
|
||||||
@@ -376,6 +475,22 @@ function runOptionLabel(run) {
|
|||||||
<pre class="log">{{ store.logLines.join("\n") || "—" }}</pre>
|
<pre class="log">{{ store.logLines.join("\n") || "—" }}</pre>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="store.alertVisible"
|
||||||
|
class="dialog-backdrop"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
@click.self="store.dismissAlert()"
|
||||||
|
>
|
||||||
|
<div class="alert-dialog panel">
|
||||||
|
<h3>Уведомление</h3>
|
||||||
|
<p class="alert-message">{{ store.alertMessage }}</p>
|
||||||
|
<div class="alert-actions">
|
||||||
|
<button type="button" class="primary" @click="store.dismissAlert()">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -390,6 +505,35 @@ function runOptionLabel(run) {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
.dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--dialog-backdrop);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
.alert-dialog {
|
||||||
|
width: min(400px, 92vw);
|
||||||
|
padding: 16px 18px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.alert-dialog h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.alert-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--dialog-hint-text);
|
||||||
|
}
|
||||||
|
.alert-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
.dataset-sidebar {
|
.dataset-sidebar {
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -454,6 +598,21 @@ function runOptionLabel(run) {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--muted-text);
|
color: var(--muted-text);
|
||||||
}
|
}
|
||||||
|
.balance-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0 2px;
|
||||||
|
border-top: 1px solid var(--header-border);
|
||||||
|
border-bottom: 1px solid var(--header-border);
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
.balance-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--control-text);
|
||||||
|
}
|
||||||
.class-legend {
|
.class-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ function applyLiveParams(fit = true) {
|
|||||||
objectRotXDeg: store.objectRotXDeg,
|
objectRotXDeg: store.objectRotXDeg,
|
||||||
objectRotYDeg: store.objectRotYDeg,
|
objectRotYDeg: store.objectRotYDeg,
|
||||||
objectRotZDeg: store.objectRotZDeg,
|
objectRotZDeg: store.objectRotZDeg,
|
||||||
|
objectSizeM: store.objectSizeM,
|
||||||
|
objectLengthM: store.objectLengthM,
|
||||||
|
objectKind: store.objectKind,
|
||||||
fitCamera: fit,
|
fitCamera: fit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -70,6 +73,8 @@ watch(
|
|||||||
store.objectY,
|
store.objectY,
|
||||||
store.objectZ,
|
store.objectZ,
|
||||||
store.objectSizeM,
|
store.objectSizeM,
|
||||||
|
store.objectLengthM,
|
||||||
|
store.objectKind,
|
||||||
store.objectRotXDeg,
|
store.objectRotXDeg,
|
||||||
store.objectRotYDeg,
|
store.objectRotYDeg,
|
||||||
store.objectRotZDeg,
|
store.objectRotZDeg,
|
||||||
@@ -79,8 +84,7 @@ watch(
|
|||||||
store.speed,
|
store.speed,
|
||||||
store.surveyLength,
|
store.surveyLength,
|
||||||
store.seed,
|
store.seed,
|
||||||
store.sizeX,
|
store.reliefScalePct,
|
||||||
store.sizeY,
|
|
||||||
store.outputDir,
|
store.outputDir,
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
@@ -97,6 +101,10 @@ function onObjectFile(event) {
|
|||||||
store.setObjectFile(event.target?.files?.[0] || null);
|
store.setObjectFile(event.target?.files?.[0] || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onObjectKindChange(event) {
|
||||||
|
store.setObjectKind(event.target?.value);
|
||||||
|
}
|
||||||
|
|
||||||
async function onPrepare() {
|
async function onPrepare() {
|
||||||
try {
|
try {
|
||||||
const result = await store.prepare();
|
const result = await store.prepare();
|
||||||
@@ -114,6 +122,8 @@ async function onPrepare() {
|
|||||||
objectY: store.objectY,
|
objectY: store.objectY,
|
||||||
objectZ: store.objectZ,
|
objectZ: store.objectZ,
|
||||||
objectSizeM: store.objectSizeM,
|
objectSizeM: store.objectSizeM,
|
||||||
|
objectLengthM: store.objectLengthM,
|
||||||
|
objectKind: store.objectKind,
|
||||||
objectRotXDeg: store.objectRotXDeg,
|
objectRotXDeg: store.objectRotXDeg,
|
||||||
objectRotYDeg: store.objectRotYDeg,
|
objectRotYDeg: store.objectRotYDeg,
|
||||||
objectRotZDeg: store.objectRotZDeg,
|
objectRotZDeg: store.objectRotZDeg,
|
||||||
@@ -207,10 +217,22 @@ function onShotSurvey() {
|
|||||||
<span class="ref-caption">{{ store.auvFileName || "Не выбрана — будет упрощённая модель" }}</span>
|
<span class="ref-caption">{{ store.auvFileName || "Не выбрана — будет упрощённая модель" }}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>3D модель объекта на дне (.obj)</span>
|
<span>Объект на дне</span>
|
||||||
<input type="file" accept=".obj" :disabled="store.busy || store.running" @change="onObjectFile" />
|
<select
|
||||||
<span class="ref-caption">{{ store.objectFileName || "Не выбрана — будет упрощённая модель" }}</span>
|
:value="store.objectKind"
|
||||||
|
:disabled="store.busy || store.running"
|
||||||
|
@change="onObjectKindChange"
|
||||||
|
>
|
||||||
|
<option value="pipe">Трубопровод (предустановка)</option>
|
||||||
|
<option value="file">Загрузить .obj…</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label v-if="store.objectKind === 'file'" class="field">
|
||||||
|
<span>Файл объекта (.obj)</span>
|
||||||
|
<input type="file" accept=".obj" :disabled="store.busy || store.running" @change="onObjectFile" />
|
||||||
|
<span class="ref-caption">{{ store.objectFileName || "Не выбран — будет упрощённая модель" }}</span>
|
||||||
|
</label>
|
||||||
|
<span v-else class="ref-caption">Прямой цилиндр: размер = диаметр, длина задаётся отдельно.</span>
|
||||||
|
|
||||||
<h3>Положение АНПА</h3>
|
<h3>Положение АНПА</h3>
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
@@ -251,9 +273,13 @@ function onShotSurvey() {
|
|||||||
<input v-model.number="store.objectZ" type="number" step="0.1" :disabled="store.running" />
|
<input v-model.number="store.objectZ" type="number" step="0.1" :disabled="store.running" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Размер объекта, м</span>
|
<span>{{ store.objectKind === "pipe" ? "Размер (диаметр), м" : "Размер объекта, м" }}</span>
|
||||||
<input v-model.number="store.objectSizeM" type="number" min="0.05" step="0.1" :disabled="store.running" />
|
<input v-model.number="store.objectSizeM" type="number" min="0.05" step="0.1" :disabled="store.running" />
|
||||||
</label>
|
</label>
|
||||||
|
<label v-if="store.objectKind === 'pipe'" class="field">
|
||||||
|
<span>Длина, м</span>
|
||||||
|
<input v-model.number="store.objectLengthM" type="number" min="0.1" step="0.5" :disabled="store.running" />
|
||||||
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Поворот X, °</span>
|
<span>Поворот X, °</span>
|
||||||
<input v-model.number="store.objectRotXDeg" type="number" step="1" :disabled="store.running" />
|
<input v-model.number="store.objectRotXDeg" type="number" step="1" :disabled="store.running" />
|
||||||
@@ -293,18 +319,25 @@ function onShotSurvey() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>Рельеф дна</h3>
|
<h3>Рельеф дна</h3>
|
||||||
|
<p class="hint">
|
||||||
|
При каждом «Подготовить» — новый случайный рельеф в жёлтой полосе обзора эхолота.
|
||||||
|
Размер неровностей задаётся в % от ширины этой полосы.
|
||||||
|
</p>
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Seed</span>
|
<span>Seed</span>
|
||||||
<input v-model.number="store.seed" type="number" step="1" :disabled="store.busy || store.running" />
|
<input v-model.number="store.seed" type="number" step="1" :disabled="store.busy || store.running" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Размер X</span>
|
<span>Размер неровностей, % ширины полосы</span>
|
||||||
<input v-model.number="store.sizeX" type="number" min="8" step="1" :disabled="store.busy || store.running" />
|
<input
|
||||||
</label>
|
v-model.number="store.reliefScalePct"
|
||||||
<label class="field">
|
type="number"
|
||||||
<span>Размер Y</span>
|
min="1"
|
||||||
<input v-model.number="store.sizeY" type="number" min="8" step="1" :disabled="store.busy || store.running" />
|
max="100"
|
||||||
|
step="1"
|
||||||
|
:disabled="store.busy || store.running"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Каталог</span>
|
<span>Каталог</span>
|
||||||
@@ -466,7 +499,8 @@ function onShotSurvey() {
|
|||||||
}
|
}
|
||||||
.field input[type="number"],
|
.field input[type="number"],
|
||||||
.field input[type="text"],
|
.field input[type="text"],
|
||||||
.field input[type="file"] {
|
.field input[type="file"],
|
||||||
|
.field select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import WizardPanel from "@/components/WizardPanel.vue";
|
|||||||
const store = usePipelineStore();
|
const store = usePipelineStore();
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
store.$subscribe(() => store.schedulePersist());
|
||||||
if (!store.stageCards.length) {
|
if (!store.stageCards.length) {
|
||||||
try {
|
try {
|
||||||
await store.bootstrap();
|
await store.bootstrap();
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user