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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-21 15:43:08 +03:00
co-authored by Cursor
parent 8b248e1d54
commit 6cfc16e53c
18 changed files with 5512 additions and 12 deletions
+14
View File
@@ -47,6 +47,20 @@ onMounted(async () => {
>
Генератор Датасета
</RouterLink>
<RouterLink
class="nav-tab"
:class="{ active: route.path.startsWith('/mle') }"
to="/mle"
>
Имитатор МЛЭ
</RouterLink>
<RouterLink
class="nav-tab"
:class="{ active: route.path.startsWith('/gbo') }"
to="/gbo"
>
Имитатор ГБО
</RouterLink>
</nav>
</div>
<div class="header-actions">
+64
View File
@@ -387,6 +387,70 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stem, outputDir, maxPoints }),
}),
datasetRuns: (outputDir = "sonar_dataset") =>
request(`/api/dataset/runs?outputDir=${encodeURIComponent(outputDir || "sonar_dataset")}`),
datasetLoad: ({ outputDir } = {}) =>
request("/api/dataset/load", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ outputDir }),
}),
mlePrepare: ({
seed = 42,
sizeX = 40,
sizeY = 60,
resX = 80,
resY = 120,
outputDir = "mle_runs",
settings = null,
} = {}) =>
request("/api/mle/prepare", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed, sizeX, sizeY, resX, resY, outputDir, settings }),
}),
mleSaveSurface: ({ outputDir, vertices, faces, filename = "seafloor.obj" } = {}) =>
request("/api/mle/save-surface", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ outputDir, vertices, faces, filename }),
}),
mleLoadSettings: ({ outputDir = "mle_runs" } = {}) =>
request(`/api/mle/settings?outputDir=${encodeURIComponent(outputDir || "mle_runs")}`),
mleSaveSettings: ({ outputDir = "mle_runs", settings = {} } = {}) =>
request("/api/mle/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ outputDir, settings }),
}),
gboPrepare: ({
seed = 42,
sizeX = 40,
sizeY = 60,
resX = 80,
resY = 120,
outputDir = "gbo_runs",
settings = null,
} = {}) =>
request("/api/gbo/prepare", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed, sizeX, sizeY, resX, resY, outputDir, settings }),
}),
gboSaveSurface: ({ outputDir, vertices, faces, filename = "seafloor.obj" } = {}) =>
request("/api/gbo/save-surface", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ outputDir, vertices, faces, filename }),
}),
gboLoadSettings: ({ outputDir = "gbo_runs" } = {}) =>
request(`/api/gbo/settings?outputDir=${encodeURIComponent(outputDir || "gbo_runs")}`),
gboSaveSettings: ({ outputDir = "gbo_runs", settings = {} } = {}) =>
request("/api/gbo/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ outputDir, settings }),
}),
};
export { exportFilename, parseObjPoints, parseXyzPoints, parsePlyPoints, parseCloudPoints };
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -2,6 +2,8 @@ import { createRouter, createWebHistory } from "vue-router";
import PipelineView from "@/views/PipelineView.vue";
import GeneratorView from "@/views/GeneratorView.vue";
import DatasetView from "@/views/DatasetView.vue";
import MleView from "@/views/MleView.vue";
import GboView from "@/views/GboView.vue";
export default createRouter({
history: createWebHistory(),
@@ -9,5 +11,7 @@ export default createRouter({
{ path: "/", name: "pipeline", component: PipelineView },
{ path: "/generator", name: "generator", component: GeneratorView },
{ path: "/dataset", name: "dataset", component: DatasetView },
{ path: "/mle", name: "mle", component: MleView },
{ path: "/gbo", name: "gbo", component: GboView },
],
});
+124 -1
View File
@@ -17,6 +17,9 @@ export const useDatasetStore = defineStore("dataset", {
beamCount: 45,
lengthCount: 45,
generateProgress: 0,
browseBusy: false,
availableRuns: [],
selectedRunPath: "",
lastResult: null,
selectedStem: null,
previewPoints: [],
@@ -43,7 +46,7 @@ export const useDatasetStore = defineStore("dataset", {
return (state.lastResult?.written || []).find((item) => item.stem === stem) || null;
},
canGenerate(state) {
return !!state.modelFile && !state.busy;
return !state.busy;
},
classOptions(state) {
const labels = state.classLabels || { 0: "background", 1: "object" };
@@ -75,6 +78,42 @@ export const useDatasetStore = defineStore("dataset", {
}
this.highlightClass = Number(value);
},
logSettingsFromRun(run) {
const settings = run?.settings || {};
const objectName = settings.objectName ?? run?.objectName;
const count = settings.count ?? run?.count;
const seed = settings.seed ?? run?.seed;
const outputDir = settings.outputDir;
const objectScale = settings.objectScale ?? run?.objectScale;
const objectScaleIsMax = settings.objectScaleIsMax ?? run?.objectScaleIsMax;
const beamCount = settings.beamCount ?? run?.beamCount;
const lengthCount = settings.lengthCount ?? run?.lengthCount;
const parts = [];
if (objectName) parts.push(`model=${objectName}`);
if (count != null) parts.push(`count=${count}`);
if (seed != null) parts.push(`seed=${seed}`);
if (beamCount != null) parts.push(`beams=${beamCount}`);
if (lengthCount != null) parts.push(`length=${lengthCount}`);
if (objectScale != null) {
parts.push(
objectScaleIsMax
? `scale=1…${objectScale} (макс.)`
: `scale=${objectScale}`,
);
}
if (outputDir) parts.push(`dir=${outputDir}`);
if (run?.objectVertexCount != null) {
parts.push(`vertices=${run.objectVertexCount}`);
}
if (run?.generatedAt) parts.push(`at=${run.generatedAt}`);
if (parts.length) {
this.pushLog(`Параметры датасета: ${parts.join(", ")}`);
} else {
this.pushLog("Параметры датасета в dataset_run.json не найдены");
}
},
applyPreviewPayload(result, writtenMeta = null) {
this.previewStem = result.stem;
this.previewPoints = result.points || [];
@@ -174,6 +213,9 @@ export const useDatasetStore = defineStore("dataset", {
this.pushLog(
`Записано ${result.count} сцен в ${result.runName || result.outputDir}. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
);
if (result.settingsPath) {
this.pushLog(`Настройки: ${result.settingsPath}`);
}
this.pushLog(
`Видимость: nearly_hidden=${s.nearly_hidden || 0}, partial=${s.partial || 0}, visible=${s.visible || 0}, absent=${s.absent || 0}.`,
);
@@ -207,6 +249,19 @@ export const useDatasetStore = defineStore("dataset", {
this.previewMeta = null;
this.classCounts = {};
}
try {
await this.refreshAvailableRuns();
const match = this.availableRuns.find(
(run) =>
run.outputDir === this.resolvedOutputDir ||
run.runName === result.runName,
);
if (match) {
this.selectedRunPath = match.loadPath || match.outputDir;
}
} catch {
/* non-fatal */
}
} catch (error) {
this.statusText = `Ошибка: ${error.message}`;
this.pushLog(`Ошибка: ${error.message}`);
@@ -242,5 +297,73 @@ export const useDatasetStore = defineStore("dataset", {
this.previewBusy = false;
}
},
async refreshAvailableRuns() {
try {
const payload = await api.datasetRuns(this.outputDir || "sonar_dataset");
this.availableRuns = payload.runs || [];
return this.availableRuns;
} catch (error) {
this.pushLog(`Ошибка списка датасетов: ${error.message}`);
throw error;
}
},
async loadDatasetRun(loadPath) {
if (!loadPath || this.browseBusy) return;
this.browseBusy = true;
this.selectedRunPath = loadPath;
this.statusText = `Загрузка датасета: ${loadPath}`;
this.pushLog(`Просмотр датасета: ${loadPath}`);
try {
const run = await api.datasetLoad({ outputDir: loadPath });
this.resolvedOutputDir = run.outputDir || loadPath;
this.lastResult = {
outputDir: run.outputDir,
runName: run.runName,
count: run.count,
seed: run.seed,
beamCount: run.beamCount,
lengthCount: run.lengthCount,
objectScale: run.objectScale,
objectScaleIsMax: run.objectScaleIsMax,
objectName: run.objectName,
stats: run.stats,
written: run.written || [],
classLabels: run.classLabels,
};
if (run.hasSettings) {
this.logSettingsFromRun(run);
} else {
this.pushLog("dataset_run.json не найден — параметры генерации недоступны");
}
const s = run.stats || {};
this.statusText = `Датасет: ${run.runName || loadPath} (${(run.written || []).length} сцен)`;
this.pushLog(
`Сцен: ${(run.written || []).length}, с объектом: ${s.withObject ?? "?"}, без: ${s.withoutObject ?? "?"}`,
);
const initialStem =
run.written?.find((item) => item.hasObject)?.stem ||
run.written?.[0]?.stem ||
null;
if (initialStem) {
await this.selectScene(initialStem);
} else {
this.selectedStem = null;
this.previewStem = null;
this.previewPoints = [];
this.previewMeta = null;
this.classCounts = {};
}
return run;
} catch (error) {
this.statusText = `Ошибка загрузки датасета: ${error.message}`;
this.pushLog(`Ошибка загрузки датасета: ${error.message}`);
throw error;
} finally {
this.browseBusy = false;
}
},
},
});
+333
View File
@@ -0,0 +1,333 @@
import { defineStore } from "pinia";
import { api } from "@/api/client";
const STORAGE_KEY = "dottosurface.gbo.settings.v1";
const DEFAULTS = {
auvX: 0,
auvY: -20,
auvDepth: 2.5,
auvHeadingDeg: 0,
auvSizeM: 10,
objectX: 12,
objectY: -20,
objectZ: 0,
objectSizeM: 2,
objectRotXDeg: 0,
objectRotYDeg: 0,
objectRotZDeg: 0,
beamCount: 4,
swathAngleDeg: 150,
detectionRangeM: 400,
speed: 1.5,
surveyLength: 40,
seed: 42,
sizeX: 40,
sizeY: 60,
outputDir: "gbo_runs",
auvFileName: "",
objectFileName: "",
};
function coerceSettings(data) {
if (!data || typeof data !== "object") return {};
const out = {};
// Migrate legacy single-axis yaw
if (data.objectRotZDeg == null && data.objectYawDeg != null) {
data = { ...data, objectRotZDeg: data.objectYawDeg };
}
if (data.object?.yawDeg != null && data.objectRotZDeg == null) {
data = { ...data, objectRotZDeg: data.object.yawDeg };
}
// Flatten nested snapshot shape if loaded from server run settings
if (data.auv && typeof data.auv === "object") {
data = {
...data,
auvX: data.auv.x ?? data.auvX,
auvY: data.auv.y ?? data.auvY,
auvDepth: data.auv.depth ?? data.auvDepth,
auvHeadingDeg: data.auv.headingDeg ?? data.auvHeadingDeg,
auvSizeM: data.auv.sizeM ?? data.auvSizeM,
};
}
if (data.object && typeof data.object === "object") {
data = {
...data,
objectX: data.object.x ?? data.objectX,
objectY: data.object.y ?? data.objectY,
objectZ: data.object.z ?? data.objectZ,
objectSizeM: data.object.sizeM ?? data.objectSizeM,
objectRotXDeg: data.object.rotXDeg ?? data.objectRotXDeg,
objectRotYDeg: data.object.rotYDeg ?? data.objectRotYDeg,
objectRotZDeg: data.object.rotZDeg ?? data.object.yawDeg ?? data.objectRotZDeg,
};
}
if (data.beams && typeof data.beams === "object") {
data = {
...data,
beamCount: data.beams.count ?? data.beamCount,
swathAngleDeg: data.beams.swathAngleDeg ?? data.swathAngleDeg,
detectionRangeM: data.beams.detectionRangeM ?? data.detectionRangeM,
};
}
if (data.motion && typeof data.motion === "object") {
data = {
...data,
speed: data.motion.speed ?? data.speed,
surveyLength: data.motion.surveyLength ?? data.surveyLength,
};
}
if (data.seafloor && typeof data.seafloor === "object") {
data = {
...data,
seed: data.seafloor.seed ?? data.seed,
sizeX: data.seafloor.sizeX ?? data.sizeX,
sizeY: data.seafloor.sizeY ?? data.sizeY,
};
}
for (const key of Object.keys(DEFAULTS)) {
if (data[key] == null) continue;
if (typeof DEFAULTS[key] === "number") {
const n = Number(data[key]);
if (Number.isFinite(n)) out[key] = n;
} else {
out[key] = String(data[key]);
}
}
return out;
}
function loadPersisted() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
return coerceSettings(JSON.parse(raw));
} catch {
return {};
}
}
export const useGboStore = defineStore("gbo", {
state: () => {
const saved = loadPersisted();
return {
busy: false,
running: false,
statusText: "Загрузите модели АНПА и объекта, задайте параметры и нажмите «Подготовить».",
logLines: [],
auvFile: null,
auvFileName: saved.auvFileName || "",
objectFile: null,
objectFileName: saved.objectFileName || "",
auvX: saved.auvX ?? DEFAULTS.auvX,
auvY: saved.auvY ?? DEFAULTS.auvY,
auvDepth: saved.auvDepth ?? DEFAULTS.auvDepth,
auvHeadingDeg: saved.auvHeadingDeg ?? DEFAULTS.auvHeadingDeg,
auvSizeM: saved.auvSizeM ?? DEFAULTS.auvSizeM,
objectX: saved.objectX ?? DEFAULTS.objectX,
objectY: saved.objectY ?? DEFAULTS.objectY,
objectZ: saved.objectZ ?? DEFAULTS.objectZ,
objectSizeM: saved.objectSizeM ?? DEFAULTS.objectSizeM,
objectRotXDeg: saved.objectRotXDeg ?? DEFAULTS.objectRotXDeg,
objectRotYDeg: saved.objectRotYDeg ?? DEFAULTS.objectRotYDeg,
objectRotZDeg: saved.objectRotZDeg ?? DEFAULTS.objectRotZDeg,
beamCount: saved.beamCount ?? DEFAULTS.beamCount,
swathAngleDeg: saved.swathAngleDeg ?? DEFAULTS.swathAngleDeg,
detectionRangeM: saved.detectionRangeM ?? DEFAULTS.detectionRangeM,
speed: saved.speed ?? DEFAULTS.speed,
surveyLength: saved.surveyLength ?? DEFAULTS.surveyLength,
seed: saved.seed ?? DEFAULTS.seed,
sizeX: saved.sizeX ?? DEFAULTS.sizeX,
sizeY: saved.sizeY ?? DEFAULTS.sizeY,
outputDir: saved.outputDir ?? DEFAULTS.outputDir,
scene: null,
runName: null,
seafloorObjPath: null,
_persistTimer: null,
};
},
getters: {
canPrepare(state) {
return !state.busy && !state.running;
},
canStart(state) {
return !!state.scene && !state.busy;
},
},
actions: {
applySettingsPatch(patch) {
const data = coerceSettings(patch);
for (const [key, value] of Object.entries(data)) {
if (key in DEFAULTS) this[key] = value;
}
},
settingsFlat() {
const payload = {};
for (const key of Object.keys(DEFAULTS)) {
payload[key] = this[key];
}
return payload;
},
persistSettings({ syncServer = true } = {}) {
const payload = this.settingsFlat();
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
} catch {
/* ignore */
}
if (!syncServer) return;
if (this._persistTimer) clearTimeout(this._persistTimer);
this._persistTimer = setTimeout(() => {
this._persistTimer = null;
void api
.gboSaveSettings({
outputDir: String(this.outputDir || "gbo_runs"),
settings: payload,
})
.catch(() => {});
}, 400);
},
async hydrateFromServer() {
try {
const result = await api.gboLoadSettings({
outputDir: String(this.outputDir || "gbo_runs"),
});
if (result?.settings) {
const localRaw = localStorage.getItem(STORAGE_KEY);
// Prefer server if local empty; otherwise keep local (fresher for same browser)
if (!localRaw) {
this.applySettingsPatch(result.settings);
this.persistSettings({ syncServer: false });
this.pushLog("Пресет настроек загружен с сервера.");
}
}
} catch {
/* offline / first run */
}
},
pushLog(line) {
this.logLines.push(String(line));
if (this.logLines.length > 300) {
this.logLines = this.logLines.slice(-300);
}
},
setAuvFile(file) {
if (!file) {
this.auvFile = null;
this.auvFileName = "";
this.persistSettings();
return;
}
const name = String(file.name || "");
if (!name.toLowerCase().endsWith(".obj")) {
this.statusText = "Модель АНПА: нужен файл .obj";
return;
}
this.auvFile = file;
this.auvFileName = name;
this.pushLog(`АНПА: ${name}`);
this.persistSettings();
},
setObjectFile(file) {
if (!file) {
this.objectFile = null;
this.objectFileName = "";
this.persistSettings();
return;
}
const name = String(file.name || "");
if (!name.toLowerCase().endsWith(".obj")) {
this.statusText = "Объект: нужен файл .obj";
return;
}
this.objectFile = file;
this.objectFileName = name;
this.pushLog(`Объект: ${name}`);
this.persistSettings();
},
placeObjectAlongTrack() {
const heading = ((Number(this.auvHeadingDeg) || 0) * Math.PI) / 180;
const ahead = Math.min(18, Math.max(6, Number(this.surveyLength) * 0.4 || 12));
this.objectX = Number(this.auvX) + Math.cos(heading) * ahead;
this.objectY = Number(this.auvY) + Math.sin(heading) * ahead;
this.objectZ = 0;
this.objectRotZDeg = Number(this.auvHeadingDeg) || 0;
},
settingsSnapshot() {
return {
...this.settingsFlat(),
auv: {
x: this.auvX,
y: this.auvY,
depth: this.auvDepth,
headingDeg: this.auvHeadingDeg,
sizeM: this.auvSizeM,
},
object: {
x: this.objectX,
y: this.objectY,
z: this.objectZ,
sizeM: this.objectSizeM,
rotXDeg: this.objectRotXDeg,
rotYDeg: this.objectRotYDeg,
rotZDeg: this.objectRotZDeg,
},
beams: {
count: this.beamCount,
swathAngleDeg: this.swathAngleDeg,
detectionRangeM: this.detectionRangeM,
},
motion: {
speed: this.speed,
surveyLength: this.surveyLength,
},
seafloor: {
seed: this.seed,
sizeX: this.sizeX,
sizeY: this.sizeY,
},
};
},
async prepare() {
if (this.busy || this.running) return;
this.placeObjectAlongTrack();
this.persistSettings();
this.busy = true;
this.statusText = "Генерация рельефа дна…";
this.pushLog(
`Подготовка: seed=${this.seed}, size=${this.sizeX}×${this.sizeY}, beams=${this.beamCount}`,
);
try {
const result = await api.gboPrepare({
seed: Number(this.seed) || 42,
sizeX: Number(this.sizeX) || 40,
sizeY: Number(this.sizeY) || 60,
resX: 80,
resY: 120,
outputDir: String(this.outputDir || "gbo_runs"),
settings: this.settingsSnapshot(),
});
this.scene = result;
this.runName = result.runName;
this.seafloorObjPath = result.seafloorObj;
this.statusText = `Рельеф сохранён: ${result.seafloorObj}`;
this.pushLog(
`Рельеф дна: ${result.mesh?.vertexCount || "?"} вершин → ${result.seafloorObj}`,
);
return result;
} catch (error) {
this.statusText = `Ошибка: ${error.message}`;
this.pushLog(`Ошибка: ${error.message}`);
throw error;
} finally {
this.busy = false;
}
},
},
});
+333
View File
@@ -0,0 +1,333 @@
import { defineStore } from "pinia";
import { api } from "@/api/client";
const STORAGE_KEY = "dottosurface.mle.settings.v1";
const DEFAULTS = {
auvX: 0,
auvY: -20,
auvDepth: 2.5,
auvHeadingDeg: 0,
auvSizeM: 10,
objectX: 12,
objectY: -20,
objectZ: 0,
objectSizeM: 2,
objectRotXDeg: 0,
objectRotYDeg: 0,
objectRotZDeg: 0,
beamCount: 45,
swathAngleDeg: 90,
detectionRangeM: 400,
speed: 1.5,
surveyLength: 40,
seed: 42,
sizeX: 40,
sizeY: 60,
outputDir: "mle_runs",
auvFileName: "",
objectFileName: "",
};
function coerceSettings(data) {
if (!data || typeof data !== "object") return {};
const out = {};
// Migrate legacy single-axis yaw
if (data.objectRotZDeg == null && data.objectYawDeg != null) {
data = { ...data, objectRotZDeg: data.objectYawDeg };
}
if (data.object?.yawDeg != null && data.objectRotZDeg == null) {
data = { ...data, objectRotZDeg: data.object.yawDeg };
}
// Flatten nested snapshot shape if loaded from server run settings
if (data.auv && typeof data.auv === "object") {
data = {
...data,
auvX: data.auv.x ?? data.auvX,
auvY: data.auv.y ?? data.auvY,
auvDepth: data.auv.depth ?? data.auvDepth,
auvHeadingDeg: data.auv.headingDeg ?? data.auvHeadingDeg,
auvSizeM: data.auv.sizeM ?? data.auvSizeM,
};
}
if (data.object && typeof data.object === "object") {
data = {
...data,
objectX: data.object.x ?? data.objectX,
objectY: data.object.y ?? data.objectY,
objectZ: data.object.z ?? data.objectZ,
objectSizeM: data.object.sizeM ?? data.objectSizeM,
objectRotXDeg: data.object.rotXDeg ?? data.objectRotXDeg,
objectRotYDeg: data.object.rotYDeg ?? data.objectRotYDeg,
objectRotZDeg: data.object.rotZDeg ?? data.object.yawDeg ?? data.objectRotZDeg,
};
}
if (data.beams && typeof data.beams === "object") {
data = {
...data,
beamCount: data.beams.count ?? data.beamCount,
swathAngleDeg: data.beams.swathAngleDeg ?? data.swathAngleDeg,
detectionRangeM: data.beams.detectionRangeM ?? data.detectionRangeM,
};
}
if (data.motion && typeof data.motion === "object") {
data = {
...data,
speed: data.motion.speed ?? data.speed,
surveyLength: data.motion.surveyLength ?? data.surveyLength,
};
}
if (data.seafloor && typeof data.seafloor === "object") {
data = {
...data,
seed: data.seafloor.seed ?? data.seed,
sizeX: data.seafloor.sizeX ?? data.sizeX,
sizeY: data.seafloor.sizeY ?? data.sizeY,
};
}
for (const key of Object.keys(DEFAULTS)) {
if (data[key] == null) continue;
if (typeof DEFAULTS[key] === "number") {
const n = Number(data[key]);
if (Number.isFinite(n)) out[key] = n;
} else {
out[key] = String(data[key]);
}
}
return out;
}
function loadPersisted() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
return coerceSettings(JSON.parse(raw));
} catch {
return {};
}
}
export const useMleStore = defineStore("mle", {
state: () => {
const saved = loadPersisted();
return {
busy: false,
running: false,
statusText: "Загрузите модели АНПА и объекта, задайте параметры и нажмите «Подготовить».",
logLines: [],
auvFile: null,
auvFileName: saved.auvFileName || "",
objectFile: null,
objectFileName: saved.objectFileName || "",
auvX: saved.auvX ?? DEFAULTS.auvX,
auvY: saved.auvY ?? DEFAULTS.auvY,
auvDepth: saved.auvDepth ?? DEFAULTS.auvDepth,
auvHeadingDeg: saved.auvHeadingDeg ?? DEFAULTS.auvHeadingDeg,
auvSizeM: saved.auvSizeM ?? DEFAULTS.auvSizeM,
objectX: saved.objectX ?? DEFAULTS.objectX,
objectY: saved.objectY ?? DEFAULTS.objectY,
objectZ: saved.objectZ ?? DEFAULTS.objectZ,
objectSizeM: saved.objectSizeM ?? DEFAULTS.objectSizeM,
objectRotXDeg: saved.objectRotXDeg ?? DEFAULTS.objectRotXDeg,
objectRotYDeg: saved.objectRotYDeg ?? DEFAULTS.objectRotYDeg,
objectRotZDeg: saved.objectRotZDeg ?? DEFAULTS.objectRotZDeg,
beamCount: saved.beamCount ?? DEFAULTS.beamCount,
swathAngleDeg: saved.swathAngleDeg ?? DEFAULTS.swathAngleDeg,
detectionRangeM: saved.detectionRangeM ?? DEFAULTS.detectionRangeM,
speed: saved.speed ?? DEFAULTS.speed,
surveyLength: saved.surveyLength ?? DEFAULTS.surveyLength,
seed: saved.seed ?? DEFAULTS.seed,
sizeX: saved.sizeX ?? DEFAULTS.sizeX,
sizeY: saved.sizeY ?? DEFAULTS.sizeY,
outputDir: saved.outputDir ?? DEFAULTS.outputDir,
scene: null,
runName: null,
seafloorObjPath: null,
_persistTimer: null,
};
},
getters: {
canPrepare(state) {
return !state.busy && !state.running;
},
canStart(state) {
return !!state.scene && !state.busy;
},
},
actions: {
applySettingsPatch(patch) {
const data = coerceSettings(patch);
for (const [key, value] of Object.entries(data)) {
if (key in DEFAULTS) this[key] = value;
}
},
settingsFlat() {
const payload = {};
for (const key of Object.keys(DEFAULTS)) {
payload[key] = this[key];
}
return payload;
},
persistSettings({ syncServer = true } = {}) {
const payload = this.settingsFlat();
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
} catch {
/* ignore */
}
if (!syncServer) return;
if (this._persistTimer) clearTimeout(this._persistTimer);
this._persistTimer = setTimeout(() => {
this._persistTimer = null;
void api
.mleSaveSettings({
outputDir: String(this.outputDir || "mle_runs"),
settings: payload,
})
.catch(() => {});
}, 400);
},
async hydrateFromServer() {
try {
const result = await api.mleLoadSettings({
outputDir: String(this.outputDir || "mle_runs"),
});
if (result?.settings) {
const localRaw = localStorage.getItem(STORAGE_KEY);
// Prefer server if local empty; otherwise keep local (fresher for same browser)
if (!localRaw) {
this.applySettingsPatch(result.settings);
this.persistSettings({ syncServer: false });
this.pushLog("Пресет настроек загружен с сервера.");
}
}
} catch {
/* offline / first run */
}
},
pushLog(line) {
this.logLines.push(String(line));
if (this.logLines.length > 300) {
this.logLines = this.logLines.slice(-300);
}
},
setAuvFile(file) {
if (!file) {
this.auvFile = null;
this.auvFileName = "";
this.persistSettings();
return;
}
const name = String(file.name || "");
if (!name.toLowerCase().endsWith(".obj")) {
this.statusText = "Модель АНПА: нужен файл .obj";
return;
}
this.auvFile = file;
this.auvFileName = name;
this.pushLog(`АНПА: ${name}`);
this.persistSettings();
},
setObjectFile(file) {
if (!file) {
this.objectFile = null;
this.objectFileName = "";
this.persistSettings();
return;
}
const name = String(file.name || "");
if (!name.toLowerCase().endsWith(".obj")) {
this.statusText = "Объект: нужен файл .obj";
return;
}
this.objectFile = file;
this.objectFileName = name;
this.pushLog(`Объект: ${name}`);
this.persistSettings();
},
placeObjectAlongTrack() {
const heading = ((Number(this.auvHeadingDeg) || 0) * Math.PI) / 180;
const ahead = Math.min(18, Math.max(6, Number(this.surveyLength) * 0.4 || 12));
this.objectX = Number(this.auvX) + Math.cos(heading) * ahead;
this.objectY = Number(this.auvY) + Math.sin(heading) * ahead;
this.objectZ = 0;
this.objectRotZDeg = Number(this.auvHeadingDeg) || 0;
},
settingsSnapshot() {
return {
...this.settingsFlat(),
auv: {
x: this.auvX,
y: this.auvY,
depth: this.auvDepth,
headingDeg: this.auvHeadingDeg,
sizeM: this.auvSizeM,
},
object: {
x: this.objectX,
y: this.objectY,
z: this.objectZ,
sizeM: this.objectSizeM,
rotXDeg: this.objectRotXDeg,
rotYDeg: this.objectRotYDeg,
rotZDeg: this.objectRotZDeg,
},
beams: {
count: this.beamCount,
swathAngleDeg: this.swathAngleDeg,
detectionRangeM: this.detectionRangeM,
},
motion: {
speed: this.speed,
surveyLength: this.surveyLength,
},
seafloor: {
seed: this.seed,
sizeX: this.sizeX,
sizeY: this.sizeY,
},
};
},
async prepare() {
if (this.busy || this.running) return;
this.placeObjectAlongTrack();
this.persistSettings();
this.busy = true;
this.statusText = "Генерация рельефа дна…";
this.pushLog(
`Подготовка: seed=${this.seed}, size=${this.sizeX}×${this.sizeY}, beams=${this.beamCount}`,
);
try {
const result = await api.mlePrepare({
seed: Number(this.seed) || 42,
sizeX: Number(this.sizeX) || 40,
sizeY: Number(this.sizeY) || 60,
resX: 80,
resY: 120,
outputDir: String(this.outputDir || "mle_runs"),
settings: this.settingsSnapshot(),
});
this.scene = result;
this.runName = result.runName;
this.seafloorObjPath = result.seafloorObj;
this.statusText = `Рельеф сохранён: ${result.seafloorObj}`;
this.pushLog(
`Рельеф дна: ${result.mesh?.vertexCount || "?"} вершин → ${result.seafloorObj}`,
);
return result;
} catch (error) {
this.statusText = `Ошибка: ${error.message}`;
this.pushLog(`Ошибка: ${error.message}`);
throw error;
} finally {
this.busy = false;
}
},
},
});
+29
View File
@@ -0,0 +1,29 @@
/** Browser PNG snapshot helpers (Pipeline-style download). */
export function formatSnapshotStamp(date = new Date()) {
const p = (n) => String(n).padStart(2, "0");
return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}_${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
}
export function buildSnapshotFilename(windowName) {
const safe =
String(windowName || "view")
.trim()
.replace(/\s+/g, "-")
.replace(/[^\w.\-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.toLowerCase() || "view";
return `dottosurface-${safe}-${formatSnapshotStamp()}.png`;
}
export function downloadPngDataUrl(dataUrl, filename) {
const link = document.createElement("a");
link.href = dataUrl;
link.download = filename;
link.rel = "noopener";
link.style.display = "none";
document.body.appendChild(link);
link.click();
link.remove();
}
+109 -3
View File
@@ -57,10 +57,15 @@ watch(
},
);
onMounted(() => {
onMounted(async () => {
if (store.previewPoints?.length) {
refreshViewer({ fit: true });
}
try {
await store.refreshAvailableRuns();
} catch {
/* logged in store */
}
});
async function onGenerate() {
@@ -92,11 +97,80 @@ function onModelFileChange(event) {
function onHighlightClassChange(event) {
store.setHighlightClass(event.target?.value);
}
async function onRefreshRuns() {
try {
await store.refreshAvailableRuns();
store.statusText = `Найдено запусков: ${store.availableRuns.length}`;
} catch {
/* status already set */
}
}
async function onSelectRun(event) {
const loadPath = event.target?.value;
if (!loadPath) return;
try {
await store.loadDatasetRun(loadPath);
} catch {
/* status already set */
}
}
function runOptionLabel(run) {
const parts = [run.runName, `${run.sceneCount} сцен`];
if (run.objectName) parts.push(run.objectName);
if (run.hasSettings) parts.push("json");
return parts.join(" · ");
}
</script>
<template>
<main class="layout dataset-layout">
<aside class="sidebar dataset-sidebar">
<section class="panel browse-panel">
<h2>Просмотр датасета</h2>
<p class="hint">
Выберите папку с ранее сгенерированными <code>.obj</code> / <code>.npy</code>.
Параметры из <code>dataset_run.json</code> выводятся в лог.
</p>
<label class="field">
<span>Папка датасета</span>
<div class="run-picker-row">
<select
:value="store.selectedRunPath || ''"
:disabled="store.browseBusy || store.busy"
@change="onSelectRun"
>
<option value=""> выберите папку </option>
<option
v-for="run in store.availableRuns"
:key="run.loadPath || run.outputDir"
:value="run.loadPath || run.outputDir"
>
{{ runOptionLabel(run) }}
</option>
</select>
<button
type="button"
class="secondary refresh-btn"
:disabled="store.browseBusy || store.busy"
title="Обновить список папок"
@click="onRefreshRuns"
>
</button>
</div>
<span class="ref-caption">
Каталог: <code>{{ store.outputDir || "sonar_dataset" }}</code>
<template v-if="store.resolvedOutputDir && store.selectedRunPath">
· открыто: <code>{{ store.resolvedOutputDir }}</code>
</template>
</span>
</label>
</section>
<section class="panel">
<h2>Генератор датасета</h2>
<p class="hint">
@@ -225,7 +299,7 @@ function onHighlightClassChange(event) {
<span>Выбор сцены</span>
<select
:value="store.selectedStem || ''"
:disabled="store.previewBusy || store.busy"
:disabled="store.previewBusy || store.busy || store.browseBusy"
@change="onSelectChange"
>
<option
@@ -271,7 +345,7 @@ function onHighlightClassChange(event) {
<button
type="button"
class="scene-btn"
:disabled="store.previewBusy || store.busy"
:disabled="store.previewBusy || store.busy || store.browseBusy"
@click="onSelectScene(item.stem)"
>
<code>{{ item.stem }}</code>
@@ -335,6 +409,38 @@ function onHighlightClassChange(event) {
margin: 0;
font-size: 16px;
}
.browse-panel {
margin-bottom: 4px;
padding-bottom: 12px;
border-bottom: 1px solid var(--header-border);
}
.run-picker-row {
display: flex;
gap: 6px;
align-items: stretch;
}
.run-picker-row select {
flex: 1 1 auto;
min-width: 0;
}
.refresh-btn {
flex: 0 0 auto;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--header-border);
background: var(--button-bg);
color: var(--control-text);
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.secondary:hover:not(:disabled) {
background: var(--chain-enabled-bg);
}
.hint {
margin: 0;
font-size: 12px;
+681
View File
@@ -0,0 +1,681 @@
<script setup>
import { onMounted, ref, watch } from "vue";
import { useGboStore } from "@/stores/gbo";
import { useGboSimulator } from "@/composables/useGboSimulator";
const store = useGboStore();
const viewerRef = ref(null);
const surveyRef = ref(null);
const sim = useGboSimulator(viewerRef, surveyRef);
const liveStatus = ref("");
function applyLiveParams(fit = true) {
if (!store.scene || store.running) return;
sim.updateParams({
beamCount: store.beamCount,
swathAngleDeg: store.swathAngleDeg,
detectionRangeM: store.detectionRangeM,
speed: store.speed,
surveyLength: store.surveyLength,
auvDepth: store.auvDepth,
auvX: store.auvX,
auvY: store.auvY,
auvHeadingDeg: store.auvHeadingDeg,
objectX: store.objectX,
objectY: store.objectY,
objectZ: store.objectZ,
objectRotXDeg: store.objectRotXDeg,
objectRotYDeg: store.objectRotYDeg,
objectRotZDeg: store.objectRotZDeg,
fitCamera: fit,
});
}
onMounted(async () => {
await store.hydrateFromServer();
if (store.auvFileName || store.objectFileName) {
const parts = [];
if (store.auvFileName) parts.push(`АНПА: ${store.auvFileName}`);
if (store.objectFileName) parts.push(`объект: ${store.objectFileName}`);
store.pushLog(`Восстановлены настройки (${parts.join(", ")}). Файлы моделей нужно выбрать снова.`);
}
sim.setCallbacks({
status: ({ traveled, x, y, z, surveyFaces }) => {
liveStatus.value = `путь ${traveled.toFixed(1)} м · (${x.toFixed(1)}, ${y.toFixed(1)}, ${z.toFixed(1)}) · поверхность ${surveyFaces || 0} граней`;
store.running = true;
},
finished: ({ traveled, saved }) => {
store.running = false;
store.statusText = `Съёмка завершена: ${traveled.toFixed(1)} м`;
store.pushLog(`Съёмка завершена: пройдено ${traveled.toFixed(1)} м`);
if (saved?.seafloorObj) {
store.seafloorObjPath = saved.seafloorObj;
store.pushLog(
`Поверхность съёмки сохранена: ${saved.seafloorObj} (${saved.vertexCount} вершин, ${saved.faceCount} граней)`,
);
}
liveStatus.value = "";
},
});
});
watch(
() => [
store.auvDepth,
store.auvX,
store.auvY,
store.auvHeadingDeg,
store.auvSizeM,
store.objectX,
store.objectY,
store.objectZ,
store.objectSizeM,
store.objectRotXDeg,
store.objectRotYDeg,
store.objectRotZDeg,
store.beamCount,
store.swathAngleDeg,
store.detectionRangeM,
store.speed,
store.surveyLength,
store.seed,
store.sizeX,
store.sizeY,
store.outputDir,
],
() => {
store.persistSettings();
applyLiveParams(true);
},
);
function onAuvFile(event) {
store.setAuvFile(event.target?.files?.[0] || null);
}
function onObjectFile(event) {
store.setObjectFile(event.target?.files?.[0] || null);
}
async function onPrepare() {
try {
const result = await store.prepare();
await sim.applyScene({
seafloorPayload: result,
auvFile: store.auvFile,
objectFile: store.objectFile,
params: {
auvX: store.auvX,
auvY: store.auvY,
auvDepth: store.auvDepth,
auvHeadingDeg: store.auvHeadingDeg,
auvSizeM: store.auvSizeM,
objectX: store.objectX,
objectY: store.objectY,
objectZ: store.objectZ,
objectSizeM: store.objectSizeM,
objectRotXDeg: store.objectRotXDeg,
objectRotYDeg: store.objectRotYDeg,
objectRotZDeg: store.objectRotZDeg,
beamCount: store.beamCount,
swathAngleDeg: store.swathAngleDeg,
detectionRangeM: store.detectionRangeM,
speed: store.speed,
surveyLength: store.surveyLength,
},
});
store.pushLog("Сцена готова. Нажмите «Старт» для движения АНПА.");
} catch {
/* logged in store */
}
}
function onStart() {
if (!store.scene) {
store.statusText = "Сначала подготовьте сцену";
return;
}
sim.updateParams({
beamCount: store.beamCount,
swathAngleDeg: store.swathAngleDeg,
detectionRangeM: store.detectionRangeM,
speed: store.speed,
surveyLength: store.surveyLength,
auvDepth: store.auvDepth,
auvX: store.auvX,
auvY: store.auvY,
auvHeadingDeg: store.auvHeadingDeg,
objectX: store.objectX,
objectY: store.objectY,
objectZ: store.objectZ,
objectRotXDeg: store.objectRotXDeg,
objectRotYDeg: store.objectRotYDeg,
objectRotZDeg: store.objectRotZDeg,
});
if (sim.start()) {
store.running = true;
store.statusText = "Съёмка…";
store.pushLog(
`Старт: скорость=${store.speed} м/с, курс=${store.auvHeadingDeg}°, глубина над дном=${store.auvDepth} м, лучей=${store.beamCount}`,
);
}
}
function onStop() {
sim.stop();
store.running = false;
store.statusText = "Остановлено";
store.pushLog("Съёмка остановлена");
liveStatus.value = "";
}
function onShotScene() {
const result = sim.captureSnapshot("scene");
if (result) {
store.pushLog(`Скриншот окна «${result.windowLabel}» сохранён: ${result.filename} (загрузки браузера)`);
} else {
store.pushLog("Скриншот сцены: окно ещё не готово");
}
}
function onShotSurvey() {
const result = sim.captureSnapshot("survey");
if (result) {
store.pushLog(`Скриншот окна «${result.windowLabel}» сохранён: ${result.filename} (загрузки браузера)`);
} else {
store.pushLog("Скриншот рельефа: окно ещё не готово");
}
}
</script>
<template>
<main class="layout gbo-layout">
<aside class="sidebar gbo-sidebar">
<section class="panel">
<h2>Имитатор ГБО</h2>
<p class="hint">
Гидролокатор бокового обзора (ГБО) на АНПА: рельеф дна, объект и движение.
На каждом борту <strong>сплошной сектор</strong> от <strong>12°</strong> до <strong>75°</strong>
от надира (симметрично). Шаг озвучивания 0.5°. Между бортами
<strong>слепая зона</strong> (|θ|&lt;12°), чёрная.
В окне съёмки: сплошное покрытие секторов + чёрные зоны вне обзора
(надир |θ|&lt;12° и |θ|&gt;75°) + акустическая тень <code>seafloor.obj</code>.
</p>
<h3>Модели</h3>
<label class="field">
<span>3D модель АНПА (.obj)</span>
<input type="file" accept=".obj" :disabled="store.busy || store.running" @change="onAuvFile" />
<span class="ref-caption">{{ store.auvFileName || "Не выбрана — будет упрощённая модель" }}</span>
</label>
<label class="field">
<span>3D модель объекта на дне (.obj)</span>
<input type="file" accept=".obj" :disabled="store.busy || store.running" @change="onObjectFile" />
<span class="ref-caption">{{ store.objectFileName || "Не выбрана — будет упрощённая модель" }}</span>
</label>
<h3>Положение АНПА</h3>
<div class="grid-2">
<label class="field">
<span>X</span>
<input v-model.number="store.auvX" type="number" step="0.5" :disabled="store.running" />
</label>
<label class="field">
<span>Y (старт)</span>
<input v-model.number="store.auvY" type="number" step="0.5" :disabled="store.running" />
</label>
<label class="field">
<span>Высота над дном, м</span>
<input v-model.number="store.auvDepth" type="number" min="0.3" step="0.1" :disabled="store.running" />
</label>
<label class="field">
<span>Курс, °</span>
<input v-model.number="store.auvHeadingDeg" type="number" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Длина АНПА, м</span>
<input v-model.number="store.auvSizeM" type="number" min="0.05" step="0.1" :disabled="store.running" />
</label>
</div>
<h3>Положение объекта</h3>
<div class="grid-2">
<label class="field">
<span>X</span>
<input v-model.number="store.objectX" type="number" step="0.5" :disabled="store.running" />
</label>
<label class="field">
<span>Y</span>
<input v-model.number="store.objectY" type="number" step="0.5" :disabled="store.running" />
</label>
<label class="field">
<span>Высота над дном</span>
<input v-model.number="store.objectZ" type="number" step="0.1" :disabled="store.running" />
</label>
<label class="field">
<span>Размер объекта, м</span>
<input v-model.number="store.objectSizeM" type="number" min="0.05" step="0.1" :disabled="store.running" />
</label>
<label class="field">
<span>Поворот X, °</span>
<input v-model.number="store.objectRotXDeg" type="number" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Поворот Y, °</span>
<input v-model.number="store.objectRotYDeg" type="number" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Поворот Z, °</span>
<input v-model.number="store.objectRotZDeg" type="number" step="1" :disabled="store.running" />
</label>
</div>
<h3>Лучи и движение</h3>
<p class="hint">Геометрия ГБО: сектор ±12°±75° от надира на каждом борту, шаг 0.5°.</p>
<div class="grid-2">
<label class="field">
<span>Число лучей</span>
<input v-model.number="store.beamCount" type="number" min="1" max="256" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Угол обзора, °</span>
<input v-model.number="store.swathAngleDeg" type="number" min="10" max="170" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Дальность, м</span>
<input v-model.number="store.detectionRangeM" type="number" min="1" max="400" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Скорость, м/с</span>
<input v-model.number="store.speed" type="number" min="0.1" step="0.1" :disabled="store.running" />
</label>
<label class="field">
<span>Длина галса, м</span>
<input v-model.number="store.surveyLength" type="number" min="1" step="1" :disabled="store.running" />
</label>
</div>
<h3>Рельеф дна</h3>
<div class="grid-2">
<label class="field">
<span>Seed</span>
<input v-model.number="store.seed" type="number" step="1" :disabled="store.busy || store.running" />
</label>
<label class="field">
<span>Размер X</span>
<input v-model.number="store.sizeX" type="number" min="8" step="1" :disabled="store.busy || store.running" />
</label>
<label class="field">
<span>Размер Y</span>
<input v-model.number="store.sizeY" type="number" min="8" step="1" :disabled="store.busy || store.running" />
</label>
<label class="field">
<span>Каталог</span>
<input v-model="store.outputDir" type="text" :disabled="store.busy || store.running" />
</label>
</div>
<div class="actions">
<button
type="button"
class="primary"
:disabled="!store.canPrepare"
@click="onPrepare"
>
{{ store.busy ? "Подготовка…" : "Подготовить" }}
</button>
<button
type="button"
class="primary"
:disabled="!store.canStart || store.running"
@click="onStart"
>
Старт
</button>
<button type="button" :disabled="!store.running" @click="onStop">
Стоп
</button>
</div>
<p class="status">{{ store.statusText }}</p>
<p v-if="liveStatus" class="live">{{ liveStatus }}</p>
<p v-if="store.seafloorObjPath" class="ref-caption">
Рельеф: <code>{{ store.seafloorObjPath }}</code>
</p>
</section>
</aside>
<div class="content-column gbo-content">
<div class="viewer-wrap">
<div ref="viewerRef" class="viewer-canvas" />
<div class="shot-bar scene-shot-bar" role="toolbar" aria-label="Скриншот сцены">
<button
type="button"
class="shot-btn"
title="Скриншот сцены"
aria-label="Скриншот сцены"
@click="onShotScene"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h4l2-3h4l2 3h4v12H4V7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
<circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="1.6" />
</svg>
</button>
</div>
<div class="viewer-label">
Имитатор ГБО
<span v-if="store.running"> · съёмка</span>
</div>
<div class="survey-panel">
<div class="survey-title">Съёмка ГБО seafloor.obj</div>
<div class="survey-canvas-wrap">
<div ref="surveyRef" class="survey-canvas" />
<div class="shot-bar survey-shot-bar" role="toolbar" aria-label="Скриншот рельефа">
<button
type="button"
class="shot-btn"
title="Скриншот рельефа"
aria-label="Скриншот рельефа"
@click="onShotSurvey"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h4l2-3h4l2 3h4v12H4V7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
<circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="1.6" />
</svg>
</button>
</div>
</div>
</div>
</div>
<section class="log-panel">
<h3>Лог</h3>
<pre class="log">{{ store.logLines.join("\n") || "—" }}</pre>
</section>
</div>
</main>
</template>
<style scoped>
.gbo-layout {
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
align-items: stretch;
height: calc(100vh - 56px);
min-height: 0;
max-height: calc(100vh - 56px);
overflow: hidden;
gap: 12px;
padding: 12px;
box-sizing: border-box;
}
.gbo-sidebar {
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
height: 100%;
max-height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
position: relative;
z-index: 2;
box-sizing: border-box;
-webkit-overflow-scrolling: touch;
}
.panel {
padding: 14px 12px 20px;
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
overflow: visible;
}
.panel h2 {
margin: 0;
font-size: 16px;
}
.panel h3 {
margin: 8px 0 0;
font-size: 13px;
color: var(--muted-text);
}
.hint {
margin: 0;
font-size: 12px;
color: var(--muted-text);
line-height: 1.4;
}
.hint code {
font-size: 11px;
}
.ref-caption {
font-size: 11px;
color: var(--muted-text);
word-break: break-all;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
.field input[type="number"],
.field input[type="text"],
.field input[type="file"] {
width: 100%;
max-width: 100%;
min-width: 0;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--header-border);
background: var(--button-bg);
color: var(--control-text);
box-sizing: border-box;
}
.grid-2 {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.actions button {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 8px 12px;
}
.primary {
font-weight: 600;
cursor: pointer;
}
.primary:disabled,
button:disabled {
opacity: 0.6;
cursor: wait;
}
.status,
.live {
margin: 0;
font-size: 12px;
color: var(--muted-text);
}
.live {
color: var(--control-text);
}
.gbo-content {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
padding: 0;
position: relative;
z-index: 1;
}
.viewer-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
border: 1px solid var(--header-border);
border-radius: var(--radius-sm, 6px);
overflow: hidden;
background: #1a3d38;
}
.viewer-canvas {
width: 100%;
height: 100%;
}
.viewer-canvas :deep(canvas) {
width: 100% !important;
height: 100% !important;
display: block;
}
.viewer-label {
position: absolute;
left: 10px;
bottom: 10px;
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.55);
color: #e2e8f0;
z-index: 2;
pointer-events: none;
}
.survey-panel {
position: absolute;
right: 10px;
bottom: 10px;
width: min(380px, 46%);
height: min(280px, 42%);
display: flex;
flex-direction: column;
border: 1px solid rgba(148, 163, 184, 0.45);
border-radius: 8px;
overflow: hidden;
background: #2a5a4a;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 3;
pointer-events: auto;
}
.survey-title {
flex: 0 0 auto;
padding: 5px 8px;
font-size: 11px;
color: #cbd5e1;
background: rgba(15, 23, 42, 0.9);
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
pointer-events: none;
}
.survey-canvas-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
width: 100%;
height: 100%;
}
.survey-canvas {
width: 100%;
height: 100%;
min-height: 0;
touch-action: none;
position: relative;
overflow: hidden;
background: #2a5a4a;
}
.survey-canvas :deep(canvas) {
width: 100% !important;
height: 100% !important;
display: block;
}
.shot-bar {
position: absolute;
z-index: 4;
display: flex;
gap: 2px;
padding: 2px 4px;
border-radius: 4px;
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
border: 1px solid var(--card-border);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
pointer-events: auto;
}
.scene-shot-bar {
top: 8px;
left: 8px;
}
.survey-shot-bar {
top: 6px;
right: 6px;
}
.shot-btn {
width: 28px;
height: 28px;
padding: 0;
display: grid;
place-items: center;
border: none;
border-radius: 3px;
background: transparent;
color: var(--modebar-icon, #e2e8f0);
cursor: pointer;
}
.shot-btn:hover {
background: color-mix(in srgb, var(--control-text) 12%, transparent);
}
.shot-btn svg {
width: 18px;
height: 18px;
}
.log-panel {
flex: 0 0 140px;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--header-border);
border-radius: var(--radius-sm, 6px);
padding: 8px 10px;
}
.log-panel h3 {
margin: 0 0 6px;
font-size: 13px;
}
.log {
margin: 0;
flex: 1;
min-height: 0;
overflow: auto;
font-size: 11px;
line-height: 1.35;
white-space: pre-wrap;
color: var(--muted-text);
}
@media (max-width: 1100px) {
.gbo-layout {
grid-template-columns: 1fr;
grid-template-rows: minmax(220px, 38vh) minmax(0, 1fr);
}
}
</style>
+678
View File
@@ -0,0 +1,678 @@
<script setup>
import { onMounted, ref, watch } from "vue";
import { useMleStore } from "@/stores/mle";
import { useMleSimulator } from "@/composables/useMleSimulator";
const store = useMleStore();
const viewerRef = ref(null);
const surveyRef = ref(null);
const sim = useMleSimulator(viewerRef, surveyRef);
const liveStatus = ref("");
function applyLiveParams(fit = true) {
if (!store.scene || store.running) return;
sim.updateParams({
beamCount: store.beamCount,
swathAngleDeg: store.swathAngleDeg,
detectionRangeM: store.detectionRangeM,
speed: store.speed,
surveyLength: store.surveyLength,
auvDepth: store.auvDepth,
auvX: store.auvX,
auvY: store.auvY,
auvHeadingDeg: store.auvHeadingDeg,
objectX: store.objectX,
objectY: store.objectY,
objectZ: store.objectZ,
objectRotXDeg: store.objectRotXDeg,
objectRotYDeg: store.objectRotYDeg,
objectRotZDeg: store.objectRotZDeg,
fitCamera: fit,
});
}
onMounted(async () => {
await store.hydrateFromServer();
if (store.auvFileName || store.objectFileName) {
const parts = [];
if (store.auvFileName) parts.push(`АНПА: ${store.auvFileName}`);
if (store.objectFileName) parts.push(`объект: ${store.objectFileName}`);
store.pushLog(`Восстановлены настройки (${parts.join(", ")}). Файлы моделей нужно выбрать снова.`);
}
sim.setCallbacks({
status: ({ traveled, x, y, z, surveyFaces }) => {
liveStatus.value = `путь ${traveled.toFixed(1)} м · (${x.toFixed(1)}, ${y.toFixed(1)}, ${z.toFixed(1)}) · поверхность ${surveyFaces || 0} граней`;
store.running = true;
},
finished: ({ traveled, saved }) => {
store.running = false;
store.statusText = `Съёмка завершена: ${traveled.toFixed(1)} м`;
store.pushLog(`Съёмка завершена: пройдено ${traveled.toFixed(1)} м`);
if (saved?.seafloorObj) {
store.seafloorObjPath = saved.seafloorObj;
store.pushLog(
`Поверхность съёмки сохранена: ${saved.seafloorObj} (${saved.vertexCount} вершин, ${saved.faceCount} граней)`,
);
}
liveStatus.value = "";
},
});
});
watch(
() => [
store.auvDepth,
store.auvX,
store.auvY,
store.auvHeadingDeg,
store.auvSizeM,
store.objectX,
store.objectY,
store.objectZ,
store.objectSizeM,
store.objectRotXDeg,
store.objectRotYDeg,
store.objectRotZDeg,
store.beamCount,
store.swathAngleDeg,
store.detectionRangeM,
store.speed,
store.surveyLength,
store.seed,
store.sizeX,
store.sizeY,
store.outputDir,
],
() => {
store.persistSettings();
applyLiveParams(true);
},
);
function onAuvFile(event) {
store.setAuvFile(event.target?.files?.[0] || null);
}
function onObjectFile(event) {
store.setObjectFile(event.target?.files?.[0] || null);
}
async function onPrepare() {
try {
const result = await store.prepare();
await sim.applyScene({
seafloorPayload: result,
auvFile: store.auvFile,
objectFile: store.objectFile,
params: {
auvX: store.auvX,
auvY: store.auvY,
auvDepth: store.auvDepth,
auvHeadingDeg: store.auvHeadingDeg,
auvSizeM: store.auvSizeM,
objectX: store.objectX,
objectY: store.objectY,
objectZ: store.objectZ,
objectSizeM: store.objectSizeM,
objectRotXDeg: store.objectRotXDeg,
objectRotYDeg: store.objectRotYDeg,
objectRotZDeg: store.objectRotZDeg,
beamCount: store.beamCount,
swathAngleDeg: store.swathAngleDeg,
detectionRangeM: store.detectionRangeM,
speed: store.speed,
surveyLength: store.surveyLength,
},
});
store.pushLog("Сцена готова. Нажмите «Старт» для движения АНПА.");
} catch {
/* logged in store */
}
}
function onStart() {
if (!store.scene) {
store.statusText = "Сначала подготовьте сцену";
return;
}
sim.updateParams({
beamCount: store.beamCount,
swathAngleDeg: store.swathAngleDeg,
detectionRangeM: store.detectionRangeM,
speed: store.speed,
surveyLength: store.surveyLength,
auvDepth: store.auvDepth,
auvX: store.auvX,
auvY: store.auvY,
auvHeadingDeg: store.auvHeadingDeg,
objectX: store.objectX,
objectY: store.objectY,
objectZ: store.objectZ,
objectRotXDeg: store.objectRotXDeg,
objectRotYDeg: store.objectRotYDeg,
objectRotZDeg: store.objectRotZDeg,
});
if (sim.start()) {
store.running = true;
store.statusText = "Съёмка…";
store.pushLog(
`Старт: скорость=${store.speed} м/с, курс=${store.auvHeadingDeg}°, глубина над дном=${store.auvDepth} м, лучей=${store.beamCount}`,
);
}
}
function onStop() {
sim.stop();
store.running = false;
store.statusText = "Остановлено";
store.pushLog("Съёмка остановлена");
liveStatus.value = "";
}
function onShotScene() {
const result = sim.captureSnapshot("scene");
if (result) {
store.pushLog(`Скриншот окна «${result.windowLabel}» сохранён: ${result.filename} (загрузки браузера)`);
} else {
store.pushLog("Скриншот сцены: окно ещё не готово");
}
}
function onShotSurvey() {
const result = sim.captureSnapshot("survey");
if (result) {
store.pushLog(`Скриншот окна «${result.windowLabel}» сохранён: ${result.filename} (загрузки браузера)`);
} else {
store.pushLog("Скриншот рельефа: окно ещё не готово");
}
}
</script>
<template>
<main class="layout mle-layout">
<aside class="sidebar mle-sidebar">
<section class="panel">
<h2>Имитатор МЛЭ</h2>
<p class="hint">
Многолучевой эхолот на АНПА: рельеф дна, объект, лучи и движение аппарата.
Объект по умолчанию ставится по курсу АНПА на дне. В правом нижнем окне
наращивается поверхность съёмки (дно + объект по первому отклику луча)
она же сохраняется в <code>seafloor.obj</code>.
</p>
<h3>Модели</h3>
<label class="field">
<span>3D модель АНПА (.obj)</span>
<input type="file" accept=".obj" :disabled="store.busy || store.running" @change="onAuvFile" />
<span class="ref-caption">{{ store.auvFileName || "Не выбрана — будет упрощённая модель" }}</span>
</label>
<label class="field">
<span>3D модель объекта на дне (.obj)</span>
<input type="file" accept=".obj" :disabled="store.busy || store.running" @change="onObjectFile" />
<span class="ref-caption">{{ store.objectFileName || "Не выбрана — будет упрощённая модель" }}</span>
</label>
<h3>Положение АНПА</h3>
<div class="grid-2">
<label class="field">
<span>X</span>
<input v-model.number="store.auvX" type="number" step="0.5" :disabled="store.running" />
</label>
<label class="field">
<span>Y (старт)</span>
<input v-model.number="store.auvY" type="number" step="0.5" :disabled="store.running" />
</label>
<label class="field">
<span>Высота над дном, м</span>
<input v-model.number="store.auvDepth" type="number" min="0.3" step="0.1" :disabled="store.running" />
</label>
<label class="field">
<span>Курс, °</span>
<input v-model.number="store.auvHeadingDeg" type="number" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Длина АНПА, м</span>
<input v-model.number="store.auvSizeM" type="number" min="0.05" step="0.1" :disabled="store.running" />
</label>
</div>
<h3>Положение объекта</h3>
<div class="grid-2">
<label class="field">
<span>X</span>
<input v-model.number="store.objectX" type="number" step="0.5" :disabled="store.running" />
</label>
<label class="field">
<span>Y</span>
<input v-model.number="store.objectY" type="number" step="0.5" :disabled="store.running" />
</label>
<label class="field">
<span>Высота над дном</span>
<input v-model.number="store.objectZ" type="number" step="0.1" :disabled="store.running" />
</label>
<label class="field">
<span>Размер объекта, м</span>
<input v-model.number="store.objectSizeM" type="number" min="0.05" step="0.1" :disabled="store.running" />
</label>
<label class="field">
<span>Поворот X, °</span>
<input v-model.number="store.objectRotXDeg" type="number" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Поворот Y, °</span>
<input v-model.number="store.objectRotYDeg" type="number" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Поворот Z, °</span>
<input v-model.number="store.objectRotZDeg" type="number" step="1" :disabled="store.running" />
</label>
</div>
<h3>Лучи и движение</h3>
<div class="grid-2">
<label class="field">
<span>Число лучей</span>
<input v-model.number="store.beamCount" type="number" min="1" max="256" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Угол обзора, °</span>
<input v-model.number="store.swathAngleDeg" type="number" min="10" max="170" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Дальность, м</span>
<input v-model.number="store.detectionRangeM" type="number" min="1" max="400" step="1" :disabled="store.running" />
</label>
<label class="field">
<span>Скорость, м/с</span>
<input v-model.number="store.speed" type="number" min="0.1" step="0.1" :disabled="store.running" />
</label>
<label class="field">
<span>Длина галса, м</span>
<input v-model.number="store.surveyLength" type="number" min="1" step="1" :disabled="store.running" />
</label>
</div>
<h3>Рельеф дна</h3>
<div class="grid-2">
<label class="field">
<span>Seed</span>
<input v-model.number="store.seed" type="number" step="1" :disabled="store.busy || store.running" />
</label>
<label class="field">
<span>Размер X</span>
<input v-model.number="store.sizeX" type="number" min="8" step="1" :disabled="store.busy || store.running" />
</label>
<label class="field">
<span>Размер Y</span>
<input v-model.number="store.sizeY" type="number" min="8" step="1" :disabled="store.busy || store.running" />
</label>
<label class="field">
<span>Каталог</span>
<input v-model="store.outputDir" type="text" :disabled="store.busy || store.running" />
</label>
</div>
<div class="actions">
<button
type="button"
class="primary"
:disabled="!store.canPrepare"
@click="onPrepare"
>
{{ store.busy ? "Подготовка…" : "Подготовить" }}
</button>
<button
type="button"
class="primary"
:disabled="!store.canStart || store.running"
@click="onStart"
>
Старт
</button>
<button type="button" :disabled="!store.running" @click="onStop">
Стоп
</button>
</div>
<p class="status">{{ store.statusText }}</p>
<p v-if="liveStatus" class="live">{{ liveStatus }}</p>
<p v-if="store.seafloorObjPath" class="ref-caption">
Рельеф: <code>{{ store.seafloorObjPath }}</code>
</p>
</section>
</aside>
<div class="content-column mle-content">
<div class="viewer-wrap">
<div ref="viewerRef" class="viewer-canvas" />
<div class="shot-bar scene-shot-bar" role="toolbar" aria-label="Скриншот сцены">
<button
type="button"
class="shot-btn"
title="Скриншот сцены"
aria-label="Скриншот сцены"
@click="onShotScene"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h4l2-3h4l2 3h4v12H4V7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
<circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="1.6" />
</svg>
</button>
</div>
<div class="viewer-label">
Имитатор МЛЭ
<span v-if="store.running"> · съёмка</span>
</div>
<div class="survey-panel">
<div class="survey-title">Съёмка рельефа seafloor.obj</div>
<div class="survey-canvas-wrap">
<div ref="surveyRef" class="survey-canvas" />
<div class="shot-bar survey-shot-bar" role="toolbar" aria-label="Скриншот рельефа">
<button
type="button"
class="shot-btn"
title="Скриншот рельефа"
aria-label="Скриншот рельефа"
@click="onShotSurvey"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h4l2-3h4l2 3h4v12H4V7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
<circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="1.6" />
</svg>
</button>
</div>
</div>
</div>
</div>
<section class="log-panel">
<h3>Лог</h3>
<pre class="log">{{ store.logLines.join("\n") || "—" }}</pre>
</section>
</div>
</main>
</template>
<style scoped>
.mle-layout {
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
align-items: stretch;
height: calc(100vh - 56px);
min-height: 0;
max-height: calc(100vh - 56px);
overflow: hidden;
gap: 12px;
padding: 12px;
box-sizing: border-box;
}
.mle-sidebar {
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
height: 100%;
max-height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
position: relative;
z-index: 2;
box-sizing: border-box;
-webkit-overflow-scrolling: touch;
}
.panel {
padding: 14px 12px 20px;
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
overflow: visible;
}
.panel h2 {
margin: 0;
font-size: 16px;
}
.panel h3 {
margin: 8px 0 0;
font-size: 13px;
color: var(--muted-text);
}
.hint {
margin: 0;
font-size: 12px;
color: var(--muted-text);
line-height: 1.4;
}
.hint code {
font-size: 11px;
}
.ref-caption {
font-size: 11px;
color: var(--muted-text);
word-break: break-all;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
.field input[type="number"],
.field input[type="text"],
.field input[type="file"] {
width: 100%;
max-width: 100%;
min-width: 0;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--header-border);
background: var(--button-bg);
color: var(--control-text);
box-sizing: border-box;
}
.grid-2 {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.actions button {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 8px 12px;
}
.primary {
font-weight: 600;
cursor: pointer;
}
.primary:disabled,
button:disabled {
opacity: 0.6;
cursor: wait;
}
.status,
.live {
margin: 0;
font-size: 12px;
color: var(--muted-text);
}
.live {
color: var(--control-text);
}
.mle-content {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
padding: 0;
position: relative;
z-index: 1;
}
.viewer-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
border: 1px solid var(--header-border);
border-radius: var(--radius-sm, 6px);
overflow: hidden;
background: #1a3d38;
}
.viewer-canvas {
width: 100%;
height: 100%;
}
.viewer-canvas :deep(canvas) {
width: 100% !important;
height: 100% !important;
display: block;
}
.viewer-label {
position: absolute;
left: 10px;
bottom: 10px;
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.55);
color: #e2e8f0;
z-index: 2;
pointer-events: none;
}
.survey-panel {
position: absolute;
right: 10px;
bottom: 10px;
width: min(380px, 46%);
height: min(280px, 42%);
display: flex;
flex-direction: column;
border: 1px solid rgba(148, 163, 184, 0.45);
border-radius: 8px;
overflow: hidden;
background: #2a5a4a;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 3;
pointer-events: auto;
}
.survey-title {
flex: 0 0 auto;
padding: 5px 8px;
font-size: 11px;
color: #cbd5e1;
background: rgba(15, 23, 42, 0.9);
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
pointer-events: none;
}
.survey-canvas-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
width: 100%;
height: 100%;
}
.survey-canvas {
width: 100%;
height: 100%;
min-height: 0;
touch-action: none;
position: relative;
overflow: hidden;
background: #2a5a4a;
}
.survey-canvas :deep(canvas) {
width: 100% !important;
height: 100% !important;
display: block;
}
.shot-bar {
position: absolute;
z-index: 4;
display: flex;
gap: 2px;
padding: 2px 4px;
border-radius: 4px;
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
border: 1px solid var(--card-border);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
pointer-events: auto;
}
.scene-shot-bar {
top: 8px;
left: 8px;
}
.survey-shot-bar {
top: 6px;
right: 6px;
}
.shot-btn {
width: 28px;
height: 28px;
padding: 0;
display: grid;
place-items: center;
border: none;
border-radius: 3px;
background: transparent;
color: var(--modebar-icon, #e2e8f0);
cursor: pointer;
}
.shot-btn:hover {
background: color-mix(in srgb, var(--control-text) 12%, transparent);
}
.shot-btn svg {
width: 18px;
height: 18px;
}
.log-panel {
flex: 0 0 140px;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--header-border);
border-radius: var(--radius-sm, 6px);
padding: 8px 10px;
}
.log-panel h3 {
margin: 0 0 6px;
font-size: 13px;
}
.log {
margin: 0;
flex: 1;
min-height: 0;
overflow: auto;
font-size: 11px;
line-height: 1.35;
white-space: pre-wrap;
color: var(--muted-text);
}
@media (max-width: 1100px) {
.mle-layout {
grid-template-columns: 1fr;
grid-template-rows: minmax(220px, 38vh) minmax(0, 1fr);
}
}
</style>