Сохраняемся

This commit is contained in:
2026-07-24 09:45:32 +03:00
parent 6cfc16e53c
commit 0614ea384a
14 changed files with 1592 additions and 142 deletions
+45 -7
View File
@@ -297,11 +297,19 @@ export const api = {
objectScaleIsMax = false,
beamCount = 45,
lengthCount = 45,
modelFile,
absentPct = 30,
nearlyHiddenPct = 20,
partialPct = 40,
visiblePct = 40,
modelPreset = null,
modelFile = null,
onProgress,
} = {}) => {
if (!modelFile) {
return Promise.reject(new Error("Выберите файл модели .obj"));
const preset = modelPreset ? String(modelPreset).trim() : "";
if (!preset && !modelFile) {
return Promise.reject(
new Error("Выберите предустановку объекта или загрузите файл модели .obj"),
);
}
const formData = new FormData();
formData.append("count", String(count));
@@ -311,7 +319,15 @@ export const api = {
formData.append("objectScaleIsMax", objectScaleIsMax ? "true" : "false");
formData.append("beamCount", String(beamCount ?? 45));
formData.append("lengthCount", String(lengthCount ?? 45));
formData.append("model", modelFile, modelFile.name || "model.obj");
formData.append("absentPct", String(absentPct ?? 30));
formData.append("nearlyHiddenPct", String(nearlyHiddenPct ?? 20));
formData.append("partialPct", String(partialPct ?? 40));
formData.append("visiblePct", String(visiblePct ?? 40));
if (preset) {
formData.append("modelPreset", preset);
} else {
formData.append("model", modelFile, modelFile.name || "model.obj");
}
const response = await fetch("/api/dataset/generate", {
method: "POST",
@@ -397,17 +413,39 @@ export const api = {
}),
mlePrepare: ({
seed = 42,
sizeX = 40,
sizeY = 60,
sizeX = null,
sizeY = null,
resX = 80,
resY = 120,
outputDir = "mle_runs",
reliefScalePct = 20,
auvX = null,
auvY = null,
auvHeadingDeg = null,
surveyLength = null,
auvDepth = null,
swathAngleDeg = null,
settings = null,
} = {}) =>
request("/api/mle/prepare", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed, sizeX, sizeY, resX, resY, outputDir, settings }),
body: JSON.stringify({
seed,
sizeX,
sizeY,
resX,
resY,
outputDir,
reliefScalePct,
auvX,
auvY,
auvHeadingDeg,
surveyLength,
auvDepth,
swathAngleDeg,
settings,
}),
}),
mleSaveSurface: ({ outputDir, vertices, faces, filename = "seafloor.obj" } = {}) =>
request("/api/mle/save-surface", {
+391 -15
View File
@@ -73,6 +73,7 @@ export function useMleSimulator(containerRef, surveyRef) {
// —— main scene ——
const SCENE_BG = 0x1a3d38;
const SEAFLOOR_COLOR = 0x3f7a62;
const RELIEF_COLOR = 0xffc107; // bright gold for path unevenness
const RAY_COLOR = 0xffb020;
const scene = new THREE.Scene();
scene.background = new THREE.Color(SCENE_BG);
@@ -115,6 +116,17 @@ export function useMleSimulator(containerRef, surveyRef) {
let animationId = 0;
let seafloorMesh = null; // unused for draw; height-field used for hits
let seafloorGrid = null;
let pathReliefMesh = null;
let pathReliefEdges = null;
let lastSeafloorPayload = null;
let surveyCorridor = {
auvX: 0,
auvY: -20,
headingDeg: 0,
surveyLength: 40,
swathAngleDeg: 90,
auvDepth: 2.5,
};
let auvPivot = null;
let objectPivot = null;
let rayLines = null;
@@ -190,15 +202,257 @@ export function useMleSimulator(containerRef, surveyRef) {
});
}
function buildSeafloor(payload) {
function featureReliefAbs(x, y, params) {
if (!params) return 0;
let z = 0;
const bumpLists = [
[params.hills || [], 1],
[params.valleys || [], -1],
[params.bumps || [], 1],
];
for (const [list, sign] of bumpLists) {
for (const item of list) {
const cx = Number(item[0]);
const cy = Number(item[1]);
const amp = Number(item[2]);
const rad = Math.max(Number(item[3]) || 0.1, 1e-6);
const d2 = (x - cx) ** 2 + (y - cy) ** 2;
if (d2 < rad * rad * 4) {
z += sign * amp * Math.exp(-d2 / (rad * rad));
}
}
}
return Math.abs(z);
}
function setSurveyCorridor(partial = {}) {
surveyCorridor = { ...surveyCorridor, ...partial };
}
function localRoughness(x, y) {
if (!meshInfo) return 0;
const z0 = sampleHeight(meshInfo, x, y);
const eps = 0.8;
return (
Math.abs(sampleHeight(meshInfo, x + eps, y) - z0) +
Math.abs(sampleHeight(meshInfo, x - eps, y) - z0) +
Math.abs(sampleHeight(meshInfo, x, y + eps) - z0) +
Math.abs(sampleHeight(meshInfo, x, y - eps) - z0)
);
}
function reliefIntensity(x, y) {
const params = meshInfo?.params || lastSeafloorPayload?.params || {};
const feature = featureReliefAbs(x, y, params);
const rough = localRoughness(x, y);
// Emphasize discrete bumps/hills and local slope along the future track.
return Math.min(1, feature / 0.45 + rough / 0.55);
}
function clearPathRelief() {
clearObject(pathReliefMesh);
clearObject(pathReliefEdges);
pathReliefMesh = null;
pathReliefEdges = null;
}
/**
* Preview of seafloor/object contacts the AUV will meet along the survey track.
* Starts at current echosounder footprint and extends forward by surveyLength.
*/
function buildPathReliefPreview() {
clearPathRelief();
if (running || !auvPivot || !meshInfo) return;
const length = Math.max(1, Number(surveyLength) || 40);
const nAlong = Math.max(28, Math.min(120, Math.ceil(length / 0.9) + 1));
const nAcross = Math.max(24, Math.min(96, Math.max(Number(beamCount) || 45, 32)));
const swath = degToRad(Math.max(5, Math.min(170, Number(swathAngleDeg) || 90)));
const maxRange = Math.max(1, Number(detectionRangeM) || DETECTION_RANGE_DEFAULT);
const hx = Math.cos(headingRad);
const hy = Math.sin(headingRad);
const acrossX = -Math.sin(headingRad);
const acrossY = Math.cos(headingRad);
const startX = auvPivot.position.x;
const startY = auvPivot.position.y;
const depth = Math.max(0.3, Number(auvDepth) || 2.5);
// Station 0: real beam hits (where rays touch seafloor/object now).
const currentHits = castBeamHits();
const positions = new Float32Array(nAlong * nAcross * 3);
const colors = new Float32Array(nAlong * nAcross * 3);
const flat = new THREE.Color(0x2f6b52);
const mid = new THREE.Color(0xe6a820);
const hot = new THREE.Color(0xfff176);
const objTint = new THREE.Color(0xff6b2d);
const writeVertex = (row, col, x, y, z, intensity, isObject) => {
const o = (row * nAcross + col) * 3;
positions[o] = x;
positions[o + 1] = y;
positions[o + 2] = z + 0.08; // slightly above so it reads over the wireframe
let c;
if (isObject) {
c = objTint;
} else if (intensity < 0.35) {
c = flat.clone().lerp(mid, intensity / 0.35);
} else {
c = mid.clone().lerp(hot, Math.min(1, (intensity - 0.35) / 0.65));
}
// Keep the whole swath visible; boost alpha via brightness on relief.
colors[o] = c.r;
colors[o + 1] = c.g;
colors[o + 2] = c.b;
};
for (let col = 0; col < nAcross; col += 1) {
const u = nAcross === 1 ? 0.5 : col / (nAcross - 1);
if (currentHits.length) {
const src = currentHits[Math.min(currentHits.length - 1, Math.round(u * (currentHits.length - 1)))];
const intensity = reliefIntensity(src.x, src.y);
writeVertex(0, col, src.x, src.y, src.z, Math.max(intensity, src.isObject ? 1 : 0.2), !!src.isObject);
} else {
const angle = -swath * 0.5 + swath * u;
const dirX = Math.sin(angle) * acrossX;
const dirY = Math.sin(angle) * acrossY;
const dirZ = -Math.cos(angle);
// fallback probe from AUV
let hitX = startX;
let hitY = startY;
let hitZ = sampleHeight(meshInfo, startX, startY);
const originZ = hitZ + depth;
const step = Math.max(0.3, maxRange / 180);
let px = startX;
let py = startY;
let pz = originZ;
for (let s = 0; s < 220; s += 1) {
px += dirX * step;
py += dirY * step;
pz += dirZ * step;
const floorZ = sampleHeight(meshInfo, px, py);
if (pz <= floorZ) {
hitX = px;
hitY = py;
hitZ = floorZ;
break;
}
}
writeVertex(0, col, hitX, hitY, hitZ, Math.max(0.15, reliefIntensity(hitX, hitY)), false);
}
}
// Forward stations: predicted beam footprint along the future track.
for (let row = 1; row < nAlong; row += 1) {
const along = (row / (nAlong - 1)) * length;
const ax = startX + hx * along;
const ay = startY + hy * along;
const floorHere = sampleHeight(meshInfo, ax, ay);
const originZ = floorHere + depth;
for (let col = 0; col < nAcross; col += 1) {
const u = nAcross === 1 ? 0.5 : col / (nAcross - 1);
const angle = -swath * 0.5 + swath * u;
const dirX = Math.sin(angle) * acrossX;
const dirY = Math.sin(angle) * acrossY;
const dirZ = -Math.cos(angle);
let hitX = ax;
let hitY = ay;
let hitZ = floorHere;
let isObject = false;
const step = Math.max(0.3, maxRange / 180);
let px = ax;
let py = ay;
let pz = originZ;
for (let s = 0; s < 220; s += 1) {
px += dirX * step;
py += dirY * step;
pz += dirZ * step;
if (Math.hypot(px - ax, py - ay) + Math.abs(pz - originZ) > maxRange * 1.15) break;
const floorZ = sampleHeight(meshInfo, px, py);
if (pz <= floorZ) {
hitX = px;
hitY = py;
hitZ = floorZ;
break;
}
}
// Object occlusion preview: if object mesh is above floor along beam, tint as object.
if (objectPivot) {
const origin = new THREE.Vector3(ax, ay, originZ);
const dir = new THREE.Vector3(dirX, dirY, dirZ).normalize();
raycaster.set(origin, dir);
raycaster.far = maxRange;
const intersects = raycaster.intersectObject(objectPivot, true);
if (intersects.length) {
const dFloor = Math.hypot(hitX - ax, hitY - ay, hitZ - originZ);
if (intersects[0].distance < dFloor) {
hitX = intersects[0].point.x;
hitY = intersects[0].point.y;
hitZ = intersects[0].point.z;
isObject = true;
}
}
}
writeVertex(row, col, hitX, hitY, hitZ, Math.max(0.12, reliefIntensity(hitX, hitY)), isObject);
}
}
const indices = [];
for (let row = 0; row < nAlong - 1; row += 1) {
for (let col = 0; col < nAcross - 1; col += 1) {
const a = row * nAcross + col;
const b = a + 1;
const c = a + nAcross;
const d = c + 1;
indices.push(a, c, b, b, c, d);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
pathReliefMesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({
vertexColors: true,
metalness: 0.05,
roughness: 0.7,
transparent: true,
opacity: 0.88,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
pathReliefMesh.renderOrder = 2;
scene.add(pathReliefMesh);
pathReliefEdges = new THREE.LineSegments(
new THREE.EdgesGeometry(geometry, 28),
new THREE.LineBasicMaterial({
color: RELIEF_COLOR,
transparent: true,
opacity: 0.95,
}),
);
pathReliefEdges.renderOrder = 3;
scene.add(pathReliefEdges);
}
function buildSeafloor(payload, corridorOpts = null) {
clearObject(seafloorMesh);
clearObject(seafloorGrid);
clearPathRelief();
seafloorMesh = null;
seafloorGrid = null;
meshInfo = null;
if (!payload?.mesh?.heights?.length && !payload?.mesh?.vertices?.length) return;
const { heights, resX, resY } = payload.mesh;
const params = payload.params || {};
if (payload) lastSeafloorPayload = payload;
if (corridorOpts) setSurveyCorridor(corridorOpts);
const src = payload || lastSeafloorPayload;
if (!src?.mesh?.heights?.length && !src?.mesh?.vertices?.length) return;
const { heights, resX, resY } = src.mesh;
const params = src.params || {};
const sizeX = Number(params.sizeX) || 40;
const sizeY = Number(params.sizeY) || 60;
meshInfo = {
@@ -208,13 +462,13 @@ export function useMleSimulator(containerRef, surveyRef) {
sizeX,
sizeY,
baseZ: params.baseZ,
params,
};
// Lightweight terrain grid (LineSegments) — infinite skirt, low vertex count
const pad = Math.max(sizeX, sizeY, detectionRangeM || 400) * 2.5;
const extSizeX = sizeX + pad * 2;
const extSizeY = sizeY + pad * 2;
// Coarse grid: enough to show relief, cheap to draw
const nX = Math.min(64, Math.max(24, Math.round(extSizeX / Math.max(sizeX / 16, 4)) + 1));
const nY = Math.min(64, Math.max(24, Math.round(extSizeY / Math.max(sizeY / 16, 4)) + 1));
const halfX = extSizeX * 0.5;
@@ -231,7 +485,6 @@ export function useMleSimulator(containerRef, surveyRef) {
pts[o + 2] = z;
}
}
// Horizontal + vertical polylines as segments
const segCount = nY * (nX - 1) + nX * (nY - 1);
const linePos = new Float32Array(segCount * 2 * 3);
let w = 0;
@@ -258,7 +511,7 @@ export function useMleSimulator(containerRef, surveyRef) {
new THREE.LineBasicMaterial({
color: SEAFLOOR_COLOR,
transparent: true,
opacity: 0.9,
opacity: 0.85,
}),
);
scene.add(seafloorGrid);
@@ -557,6 +810,29 @@ export function useMleSimulator(containerRef, surveyRef) {
return normalizeModel(g, Math.max(0.05, Number(sizeM) || 2));
}
/** Straight horizontal cylinder: sizeM = diameter, lengthM = length along local X. */
function makePipelineObject(sizeM = 2, lengthM = 20) {
const diameter = Math.max(0.05, Number(sizeM) || 2);
const length = Math.max(0.1, Number(lengthM) || 20);
const radius = diameter * 0.5;
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(radius, radius, length, 36, 1, false),
new THREE.MeshStandardMaterial({
color: 0xf59e0b,
metalness: 0.28,
roughness: 0.55,
flatShading: false,
}),
);
// Default cylinder axis is Y → rotate onto X (horizontal pipeline).
mesh.rotation.z = Math.PI / 2;
mesh.castShadow = true;
mesh.receiveShadow = true;
const g = new THREE.Group();
g.add(mesh);
return g;
}
async function setAuvModel(file, sizeM = 10, x = 0, y = 0, depth = 2.5, headingDeg = 0) {
clearObject(auvPivot);
auvPivot = new THREE.Group();
@@ -573,17 +849,43 @@ export function useMleSimulator(containerRef, surveyRef) {
placeAuv(x, y, depth, headingDeg);
}
async function setObjectModel(file, sizeM = 2, x = 0, y = 0, zOffset = 0, rotXDeg = 0, rotYDeg = 0, rotZDeg = 0) {
let lastObjectFile = null;
let lastObjectKind = "file";
let lastObjectSizeM = 2;
let lastObjectLengthM = 20;
async function setObjectModel(
file,
sizeM = 2,
x = 0,
y = 0,
zOffset = 0,
rotXDeg = 0,
rotYDeg = 0,
rotZDeg = 0,
options = {},
) {
clearObject(objectPivot);
objectPivot = new THREE.Group();
const targetSize = Math.max(0.05, Number(sizeM) || 2);
const kind = options.kind === "pipe" ? "pipe" : "file";
const lengthM = Math.max(0.1, Number(options.lengthM) || 20);
lastObjectFile = file || null;
lastObjectKind = kind;
lastObjectSizeM = targetSize;
lastObjectLengthM = lengthM;
let model = null;
try {
model = await loadObjFile(file, targetSize);
} catch {
model = null;
if (kind === "pipe") {
model = makePipelineObject(targetSize, lengthM);
} else {
try {
model = await loadObjFile(file, targetSize);
} catch {
model = null;
}
if (!model) model = makeFallbackObject(targetSize);
}
if (!model) model = makeFallbackObject(targetSize);
objectPivot.add(model);
scene.add(objectPivot);
placeObject(x, y, zOffset, rotXDeg, rotYDeg, rotZDeg);
@@ -718,6 +1020,8 @@ export function useMleSimulator(containerRef, surveyRef) {
appendSurveyStrip(hits);
void persistSurveySurface(false);
}
} else {
buildPathReliefPreview();
}
}
@@ -822,8 +1126,6 @@ export function useMleSimulator(containerRef, surveyRef) {
async function applyScene({ seafloorPayload, auvFile, objectFile, params }) {
runOutputDir = seafloorPayload?.outputDir || null;
buildSeafloor(seafloorPayload);
resetSurveySurface();
beamCount = params.beamCount ?? 45;
swathAngleDeg = params.swathAngleDeg ?? 90;
detectionRangeM = Math.max(1, Number(params.detectionRangeM) || DETECTION_RANGE_DEFAULT);
@@ -831,6 +1133,15 @@ export function useMleSimulator(containerRef, surveyRef) {
speed = Math.max(0.05, Number(params.speed) || 1.5);
surveyLength = Math.max(1, Number(params.surveyLength) || 40);
auvDepth = Math.max(0.3, Number(params.auvDepth) || 2.5);
buildSeafloor(seafloorPayload, {
auvX: params.auvX,
auvY: params.auvY,
headingDeg: params.auvHeadingDeg,
surveyLength,
swathAngleDeg,
auvDepth,
});
resetSurveySurface();
await setAuvModel(
auvFile,
params.auvSizeM,
@@ -848,6 +1159,10 @@ export function useMleSimulator(containerRef, surveyRef) {
params.objectRotXDeg,
params.objectRotYDeg,
params.objectRotZDeg ?? params.objectYawDeg,
{
kind: params.objectKind,
lengthM: params.objectLengthM,
},
);
trailPoints.length = 0;
traveled = 0;
@@ -858,6 +1173,7 @@ export function useMleSimulator(containerRef, surveyRef) {
function start() {
if (!auvPivot || !meshInfo) return false;
resetSurveySurface();
clearPathRelief();
running = true;
lastTs = 0;
traveled = 0;
@@ -869,6 +1185,7 @@ export function useMleSimulator(containerRef, surveyRef) {
function stop() {
running = false;
void persistSurveySurface(true);
buildPathReliefPreview();
}
function isRunning() {
@@ -891,6 +1208,27 @@ export function useMleSimulator(containerRef, surveyRef) {
if (params.surveyLength != null) surveyLength = Math.max(1, Number(params.surveyLength) || 40);
if (params.auvDepth != null) auvDepth = Math.max(0.3, Number(params.auvDepth) || 2.5);
const corridorChanged =
params.auvX != null ||
params.auvY != null ||
params.auvHeadingDeg != null ||
params.surveyLength != null ||
params.swathAngleDeg != null ||
params.auvDepth != null;
if (corridorChanged) {
setSurveyCorridor({
auvX: params.auvX ?? surveyCorridor.auvX,
auvY: params.auvY ?? surveyCorridor.auvY,
headingDeg: params.auvHeadingDeg ?? surveyCorridor.headingDeg,
surveyLength,
swathAngleDeg,
auvDepth,
});
if (lastSeafloorPayload && !running) {
buildPathReliefPreview();
}
}
let moved = false;
if (!running && auvPivot && (params.auvX != null || params.auvY != null || params.auvDepth != null || params.auvHeadingDeg != null)) {
placeAuv(
@@ -927,6 +1265,43 @@ export function useMleSimulator(containerRef, surveyRef) {
if (!running) updateRays(false);
moved = true;
}
const nextKind = params.objectKind === "pipe" ? "pipe" : params.objectKind != null ? "file" : null;
const nextSize =
params.objectSizeM != null ? Math.max(0.05, Number(params.objectSizeM) || 2) : null;
const nextLen =
params.objectLengthM != null ? Math.max(0.1, Number(params.objectLengthM) || 20) : null;
const rebuildObject =
objectPivot &&
meshInfo &&
!running &&
((nextKind != null && nextKind !== lastObjectKind) ||
(nextSize != null && Math.abs(nextSize - lastObjectSizeM) > 1e-6) ||
(nextLen != null && Math.abs(nextLen - lastObjectLengthM) > 1e-6));
if (rebuildObject) {
void setObjectModel(
lastObjectFile,
nextSize ?? lastObjectSizeM,
params.objectX ?? objectPivot.position.x,
params.objectY ?? objectPivot.position.y,
params.objectZ != null ? params.objectZ : lastObjectZ,
params.objectRotXDeg != null ? params.objectRotXDeg : lastObjectRotXDeg,
params.objectRotYDeg != null ? params.objectRotYDeg : lastObjectRotYDeg,
params.objectRotZDeg != null
? params.objectRotZDeg
: params.objectYawDeg != null
? params.objectYawDeg
: lastObjectRotZDeg,
{
kind: nextKind ?? lastObjectKind,
lengthM: nextLen ?? lastObjectLengthM,
},
).then(() => {
if (!running) updateRays(false);
fitCamera();
});
moved = true;
}
if (moved || params.fitCamera) fitCamera();
}
@@ -975,6 +1350,7 @@ export function useMleSimulator(containerRef, surveyRef) {
surveyControls?.dispose();
clearObject(seafloorMesh);
clearObject(seafloorGrid);
clearPathRelief();
clearObject(auvPivot);
clearObject(objectPivot);
clearObject(rayLines);
+146 -17
View File
@@ -1,21 +1,61 @@
import { defineStore } from "pinia";
import { api } from "@/api/client";
import { createDebouncedSaver, loadUserSettings, saveUserSettings } from "@/utils/userSettings";
const STORAGE_KEY = "dottosurface.dataset.settings.v1";
const PERSIST_KEYS = [
"count",
"seed",
"outputDir",
"modelSource",
"modelPreset",
"modelFileName",
"objectScale",
"objectScaleIsMax",
"beamCount",
"lengthCount",
"absentPct",
"nearlyHiddenPct",
"partialPct",
"visiblePct",
];
function loadPersistedDataset() {
const saved = loadUserSettings(STORAGE_KEY, {});
const out = {};
for (const key of PERSIST_KEYS) {
if (saved[key] == null) continue;
out[key] = saved[key];
}
if (out.modelSource !== "file" && out.modelSource !== "preset") out.modelSource = "preset";
if (!out.modelPreset) out.modelPreset = "pipe";
return out;
}
export const useDatasetStore = defineStore("dataset", {
state: () => ({
state: () => {
const saved = loadPersistedDataset();
return {
busy: false,
previewBusy: false,
statusText: "Выберите .obj модель и нажмите «Сгенерировать».",
count: 5,
seed: 42,
outputDir: "sonar_dataset",
statusText: "Выберите модель объекта и нажмите «Сгенерировать».",
count: saved.count ?? 5,
seed: saved.seed ?? 42,
outputDir: saved.outputDir ?? "sonar_dataset",
resolvedOutputDir: null,
modelSource: saved.modelSource ?? "preset",
modelPreset: saved.modelPreset ?? "pipe",
modelFile: null,
modelFileName: "",
objectScale: 1,
objectScaleIsMax: false,
beamCount: 45,
lengthCount: 45,
modelFileName: saved.modelFileName ?? "",
objectScale: saved.objectScale ?? 1,
objectScaleIsMax: !!saved.objectScaleIsMax,
beamCount: saved.beamCount ?? 45,
lengthCount: saved.lengthCount ?? 45,
absentPct: saved.absentPct ?? 30,
nearlyHiddenPct: saved.nearlyHiddenPct ?? 20,
partialPct: saved.partialPct ?? 40,
visiblePct: saved.visiblePct ?? 40,
generateProgress: 0,
browseBusy: false,
availableRuns: [],
@@ -29,7 +69,11 @@ export const useDatasetStore = defineStore("dataset", {
classLabels: { 0: "background", 1: "object" },
classCounts: {},
logLines: [],
}),
alertVisible: false,
alertMessage: "",
_persistTimer: null,
};
},
getters: {
stats(state) {
return state.lastResult?.stats || null;
@@ -48,6 +92,19 @@ export const useDatasetStore = defineStore("dataset", {
canGenerate(state) {
return !state.busy;
},
selectedModelLabel(state) {
if (state.modelSource === "preset") {
if (state.modelPreset === "pipe") return "Трубопровод (предустановка)";
return state.modelPreset ? `Предустановка: ${state.modelPreset}` : "";
}
return state.modelFileName || "";
},
hasObjectModel(state) {
if (state.modelSource === "preset") {
return Boolean(state.modelPreset);
}
return Boolean(state.modelFile);
},
classOptions(state) {
const labels = state.classLabels || { 0: "background", 1: "object" };
const counts = state.classCounts || {};
@@ -65,12 +122,35 @@ export const useDatasetStore = defineStore("dataset", {
},
},
actions: {
persistSettings() {
const payload = {};
for (const key of PERSIST_KEYS) {
payload[key] = this[key];
}
saveUserSettings(STORAGE_KEY, payload);
},
schedulePersist() {
if (!this._debouncedPersist) {
this._debouncedPersist = createDebouncedSaver(() => this.persistSettings(), 400);
}
this._debouncedPersist();
},
pushLog(line) {
this.logLines.push(String(line));
if (this.logLines.length > 200) {
this.logLines = this.logLines.slice(-200);
}
},
showAlert(message) {
const text = String(message || "").trim();
if (!text) return;
this.alertMessage = text;
this.alertVisible = true;
},
dismissAlert() {
this.alertVisible = false;
this.alertMessage = "";
},
setHighlightClass(value) {
if (value === null || value === undefined || value === "" || value === "all") {
this.highlightClass = null;
@@ -88,6 +168,10 @@ export const useDatasetStore = defineStore("dataset", {
const objectScaleIsMax = settings.objectScaleIsMax ?? run?.objectScaleIsMax;
const beamCount = settings.beamCount ?? run?.beamCount;
const lengthCount = settings.lengthCount ?? run?.lengthCount;
const absentPct = settings.absentPct;
const nearlyHiddenPct = settings.nearlyHiddenPct;
const partialPct = settings.partialPct;
const visiblePct = settings.visiblePct;
const parts = [];
if (objectName) parts.push(`model=${objectName}`);
@@ -102,6 +186,16 @@ export const useDatasetStore = defineStore("dataset", {
: `scale=${objectScale}`,
);
}
if (absentPct != null) parts.push(`без объекта=${absentPct}%`);
if (
nearlyHiddenPct != null ||
partialPct != null ||
visiblePct != null
) {
parts.push(
`видимость(с объектом)=${nearlyHiddenPct ?? "?"}/${partialPct ?? "?"}/${visiblePct ?? "?"}%`,
);
}
if (outputDir) parts.push(`dir=${outputDir}`);
if (run?.objectVertexCount != null) {
parts.push(`vertices=${run.objectVertexCount}`);
@@ -134,7 +228,7 @@ export const useDatasetStore = defineStore("dataset", {
if (!file) {
this.modelFile = null;
this.modelFileName = "";
this.statusText = "Выберите .obj модель и нажмите «Сгенерировать».";
this.statusText = "Выберите модель объекта и нажмите «Сгенерировать».";
return;
}
const name = String(file.name || "");
@@ -144,22 +238,52 @@ export const useDatasetStore = defineStore("dataset", {
this.statusText = "Нужен файл формата .obj";
return;
}
this.modelSource = "file";
this.modelFile = file;
this.modelFileName = name;
this.statusText = `Модель: ${name}`;
this.pushLog(`Выбрана модель: ${name}`);
this.schedulePersist();
},
setModelSource(source) {
const next = source === "file" ? "file" : "preset";
this.modelSource = next;
if (next === "preset") {
if (!this.modelPreset) this.modelPreset = "pipe";
this.statusText = `Модель: ${this.selectedModelLabel}`;
this.pushLog(`Предустановка объекта: ${this.selectedModelLabel}`);
} else {
this.statusText = this.modelFileName
? `Модель: ${this.modelFileName}`
: "Выберите файл модели .obj";
}
this.schedulePersist();
},
setModelPreset(presetId) {
this.modelPreset = String(presetId || "pipe");
this.modelSource = "preset";
this.statusText = `Модель: ${this.selectedModelLabel}`;
this.pushLog(`Предустановка объекта: ${this.selectedModelLabel}`);
this.schedulePersist();
},
async generate() {
if (this.busy) return;
if (!this.modelFile) {
this.statusText = "Сначала выберите файл модели .obj";
if (!this.hasObjectModel) {
const msg =
this.modelSource === "file"
? "Файл объекта не выбран. Выберите модель .obj перед генерацией."
: "Модель объекта не выбрана.";
this.statusText = msg;
this.pushLog(msg);
this.showAlert(msg);
return;
}
this.busy = true;
this.generateProgress = 0;
this.statusText = "Генерация датасета…";
const modelLabel = this.selectedModelLabel || this.modelFileName || this.modelPreset;
this.pushLog(
`Старт: count=${this.count}, seed=${this.seed}, beams=${this.beamCount}, length=${this.lengthCount}, scale=${this.objectScale}${this.objectScaleIsMax ? " (макс.)" : ""}, dir=${this.outputDir}, model=${this.modelFileName}`,
`Старт: count=${this.count}, seed=${this.seed}, beams=${this.beamCount}, length=${this.lengthCount}, scale=${this.objectScale}${this.objectScaleIsMax ? " (макс.)" : ""}, без объекта=${this.absentPct}%, видимость=${this.nearlyHiddenPct}/${this.partialPct}/${this.visiblePct}%, dir=${this.outputDir}, model=${modelLabel}`,
);
try {
const result = await api.datasetGenerate({
@@ -170,7 +294,12 @@ export const useDatasetStore = defineStore("dataset", {
objectScaleIsMax: !!this.objectScaleIsMax,
beamCount: Number(this.beamCount) || 45,
lengthCount: Number(this.lengthCount) || 45,
modelFile: this.modelFile,
absentPct: Number(this.absentPct) || 0,
nearlyHiddenPct: Number(this.nearlyHiddenPct) || 0,
partialPct: Number(this.partialPct) || 0,
visiblePct: Number(this.visiblePct) || 0,
modelPreset: this.modelSource === "preset" ? this.modelPreset : null,
modelFile: this.modelSource === "file" ? this.modelFile : null,
onProgress: (event) => {
if (event.type === "start") {
this.generateProgress = 0;
@@ -208,7 +337,7 @@ export const useDatasetStore = defineStore("dataset", {
? `scale=1…${result.objectScale ?? this.objectScale} (макс.)`
: `scale=${result.objectScale ?? this.objectScale}`;
this.pushLog(
`Модель: ${result.objectName || this.modelFileName} (${result.objectVertexCount || "?"} вершин), ${scaleNote}, ширина=${result.beamCount ?? this.beamCount}, длина=${result.lengthCount ?? this.lengthCount}. class 1 = object.`,
`Модель: ${result.objectName || modelLabel} (${result.objectVertexCount || "?"} вершин), ${scaleNote}, ширина=${result.beamCount ?? this.beamCount}, длина=${result.lengthCount ?? this.lengthCount}. class 1 = object.`,
);
this.pushLog(
`Записано ${result.count} сцен в ${result.runName || result.outputDir}. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
+110 -7
View File
@@ -1,8 +1,10 @@
import { defineStore } from "pinia";
import { api, exportFilename, parseCloudPoints } from "@/api/client";
import { createDebouncedSaver, loadUserSettings, saveUserSettings } from "@/utils/userSettings";
let layerSeq = 1;
const MAX_HISTORY = 80;
const STORAGE_KEY = "dottosurface.generator.settings.v1";
const IMPORT_COLORS = {
obj: "#f472b6",
ply: "#38bdf8",
@@ -67,23 +69,54 @@ function makeSnapshot(label, layers, selectedLayerId) {
};
}
function layerRecipe(layer) {
return {
id: layer.id,
name: layer.name,
kind: layer.kind,
type: layer.type,
label: layer.label,
params: layer.params || {},
transform: normalizeTransform(layer.transform),
visible: layer.visible !== false,
color: layer.color,
imported: !!layer.imported,
};
}
function loadPersistedGenerator() {
const saved = loadUserSettings(STORAGE_KEY, {});
return {
exportFormat: saved.exportFormat || "xyz",
interactionMode: saved.interactionMode || "orbit",
translateStep: Number(saved.translateStep) || 0.05,
rotateStepDeg: Number(saved.rotateStepDeg) || 5,
selectedLayerId: saved.selectedLayerId || null,
layerRecipes: Array.isArray(saved.layerRecipes) ? saved.layerRecipes : [],
};
}
export const useGeneratorStore = defineStore("generator", {
state: () => ({
state: () => {
const saved = loadPersistedGenerator();
return {
busy: false,
statusText: "Добавьте объект или поверхность.",
catalog: [],
colors: {},
layers: [],
selectedLayerId: null,
interactionMode: "orbit",
translateStep: 0.05,
rotateStepDeg: 5,
exportFormat: "xyz",
selectedLayerId: saved.selectedLayerId,
interactionMode: saved.interactionMode,
translateStep: saved.translateStep,
rotateStepDeg: saved.rotateStepDeg,
exportFormat: saved.exportFormat,
viewerRevision: 0,
history: [makeSnapshot("Начало", [], null)],
historyIndex: 0,
_restoring: false,
}),
_pendingRecipes: saved.layerRecipes,
};
},
getters: {
selectedLayer(state) {
return state.layers.find((layer) => layer.id === state.selectedLayerId) || null;
@@ -111,11 +144,75 @@ export const useGeneratorStore = defineStore("generator", {
},
},
actions: {
persistSettings() {
saveUserSettings(STORAGE_KEY, {
exportFormat: this.exportFormat,
interactionMode: this.interactionMode,
translateStep: this.translateStep,
rotateStepDeg: this.rotateStepDeg,
selectedLayerId: this.selectedLayerId,
layerRecipes: this.layers.map(layerRecipe),
});
},
schedulePersist() {
if (this._restoring) return;
if (!this._debouncedPersist) {
this._debouncedPersist = createDebouncedSaver(() => this.persistSettings(), 500);
}
this._debouncedPersist();
},
async bootstrap() {
const payload = await api.generatorCatalog();
this.catalog = payload.layers || [];
this.colors = payload.colors || {};
this.statusText = "Каталог загружен. Добавьте слой.";
await this.restoreLayerRecipes(this._pendingRecipes || []);
this._pendingRecipes = [];
},
async restoreLayerRecipes(recipes) {
if (!recipes.length) return;
this._restoring = true;
this.busy = true;
try {
const restored = [];
for (const recipe of recipes) {
if (recipe.imported) continue;
if (!recipe.kind || !recipe.type) continue;
try {
const result = await api.generatorLayer(recipe.kind, recipe.type, recipe.params || {});
const id = recipe.id || `layer-${layerSeq++}`;
const num = Number(String(id).replace(/\D/g, "")) || layerSeq;
layerSeq = Math.max(layerSeq, num + 1);
restored.push({
id,
name: recipe.name || result.label || recipe.type,
kind: recipe.kind,
type: recipe.type,
label: result.label || recipe.label,
params: result.params || recipe.params || {},
transform: normalizeTransform(recipe.transform),
points: result.points || [],
visible: recipe.visible !== false,
color: recipe.color || result.color || "#7dd3fc",
});
} catch {
/* skip broken recipe */
}
}
this.layers = restored;
if (restored.length) {
const still = restored.find((l) => l.id === this.selectedLayerId);
this.selectedLayerId = still?.id || restored[0].id;
this.history = [makeSnapshot("Восстановлено", this.layers, this.selectedLayerId)];
this.historyIndex = 0;
this.statusText = `Восстановлено слоёв: ${restored.length}.`;
this.bumpViewer();
}
} finally {
this.busy = false;
this._restoring = false;
this.persistSettings();
}
},
bumpViewer() {
this.viewerRevision += 1;
@@ -145,6 +242,7 @@ export const useGeneratorStore = defineStore("generator", {
this.history.splice(0, this.history.length - MAX_HISTORY);
}
this.historyIndex = this.history.length - 1;
this.schedulePersist();
} catch (error) {
console.error("recordHistory failed", error);
this.statusText = `История не записана: ${error.message}`;
@@ -181,6 +279,7 @@ export const useGeneratorStore = defineStore("generator", {
},
selectLayer(id) {
this.selectedLayerId = id;
this.schedulePersist();
},
setInteractionMode(mode) {
if (mode === "translate" || mode === "rotate") {
@@ -188,18 +287,22 @@ export const useGeneratorStore = defineStore("generator", {
} else {
this.interactionMode = "orbit";
}
this.schedulePersist();
},
setTranslateStep(step) {
const value = Number(step);
this.translateStep = Number.isFinite(value) && value > 0 ? value : 0.05;
this.schedulePersist();
},
setRotateStepDeg(step) {
const value = Number(step);
this.rotateStepDeg = Number.isFinite(value) && value > 0 ? value : 5;
this.schedulePersist();
},
setExportFormat(format) {
const allowed = new Set(["xyz", "ply", "obj", "npy"]);
this.exportFormat = allowed.has(format) ? format : "xyz";
this.schedulePersist();
},
updateSelectedParams(partial) {
const layer = this.selectedLayer;
+39 -3
View File
@@ -13,6 +13,8 @@ const DEFAULTS = {
objectY: -20,
objectZ: 0,
objectSizeM: 2,
objectLengthM: 20,
objectKind: "pipe", // "pipe" | "file"
objectRotXDeg: 0,
objectRotYDeg: 0,
objectRotZDeg: 0,
@@ -22,6 +24,7 @@ const DEFAULTS = {
speed: 1.5,
surveyLength: 40,
seed: 42,
reliefScalePct: 20,
sizeX: 40,
sizeY: 60,
outputDir: "mle_runs",
@@ -57,6 +60,8 @@ function coerceSettings(data) {
objectY: data.object.y ?? data.objectY,
objectZ: data.object.z ?? data.objectZ,
objectSizeM: data.object.sizeM ?? data.objectSizeM,
objectLengthM: data.object.lengthM ?? data.objectLengthM,
objectKind: data.object.kind ?? data.objectKind,
objectRotXDeg: data.object.rotXDeg ?? data.objectRotXDeg,
objectRotYDeg: data.object.rotYDeg ?? data.objectRotYDeg,
objectRotZDeg: data.object.rotZDeg ?? data.object.yawDeg ?? data.objectRotZDeg,
@@ -81,12 +86,17 @@ function coerceSettings(data) {
data = {
...data,
seed: data.seafloor.seed ?? data.seed,
reliefScalePct: data.seafloor.reliefScalePct ?? data.reliefScalePct,
sizeX: data.seafloor.sizeX ?? data.sizeX,
sizeY: data.seafloor.sizeY ?? data.sizeY,
};
}
for (const key of Object.keys(DEFAULTS)) {
if (data[key] == null) continue;
if (key === "objectKind") {
out[key] = String(data[key]) === "pipe" ? "pipe" : "file";
continue;
}
if (typeof DEFAULTS[key] === "number") {
const n = Number(data[key]);
if (Number.isFinite(n)) out[key] = n;
@@ -131,6 +141,8 @@ export const useMleStore = defineStore("mle", {
objectY: saved.objectY ?? DEFAULTS.objectY,
objectZ: saved.objectZ ?? DEFAULTS.objectZ,
objectSizeM: saved.objectSizeM ?? DEFAULTS.objectSizeM,
objectLengthM: saved.objectLengthM ?? DEFAULTS.objectLengthM,
objectKind: saved.objectKind ?? DEFAULTS.objectKind,
objectRotXDeg: saved.objectRotXDeg ?? DEFAULTS.objectRotXDeg,
objectRotYDeg: saved.objectRotYDeg ?? DEFAULTS.objectRotYDeg,
objectRotZDeg: saved.objectRotZDeg ?? DEFAULTS.objectRotZDeg,
@@ -142,6 +154,7 @@ export const useMleStore = defineStore("mle", {
surveyLength: saved.surveyLength ?? DEFAULTS.surveyLength,
seed: saved.seed ?? DEFAULTS.seed,
reliefScalePct: saved.reliefScalePct ?? DEFAULTS.reliefScalePct,
sizeX: saved.sizeX ?? DEFAULTS.sizeX,
sizeY: saved.sizeY ?? DEFAULTS.sizeY,
outputDir: saved.outputDir ?? DEFAULTS.outputDir,
@@ -246,11 +259,20 @@ export const useMleStore = defineStore("mle", {
this.statusText = "Объект: нужен файл .obj";
return;
}
this.objectKind = "file";
this.objectFile = file;
this.objectFileName = name;
this.pushLog(`Объект: ${name}`);
this.persistSettings();
},
setObjectKind(kind) {
const next = kind === "pipe" ? "pipe" : "file";
this.objectKind = next;
if (next === "pipe") {
this.pushLog("Объект: трубопровод (предустановка)");
}
this.persistSettings();
},
placeObjectAlongTrack() {
const heading = ((Number(this.auvHeadingDeg) || 0) * Math.PI) / 180;
const ahead = Math.min(18, Math.max(6, Number(this.surveyLength) * 0.4 || 12));
@@ -274,6 +296,8 @@ export const useMleStore = defineStore("mle", {
y: this.objectY,
z: this.objectZ,
sizeM: this.objectSizeM,
lengthM: this.objectLengthM,
kind: this.objectKind,
rotXDeg: this.objectRotXDeg,
rotYDeg: this.objectRotYDeg,
rotZDeg: this.objectRotZDeg,
@@ -289,6 +313,7 @@ export const useMleStore = defineStore("mle", {
},
seafloor: {
seed: this.seed,
reliefScalePct: this.reliefScalePct,
sizeX: this.sizeX,
sizeY: this.sizeY,
},
@@ -296,23 +321,34 @@ export const useMleStore = defineStore("mle", {
},
async prepare() {
if (this.busy || this.running) return;
// Новый случайный рельеф при каждой подготовке.
this.seed = Math.floor(Math.random() * 1_000_000);
this.placeObjectAlongTrack();
this.persistSettings();
this.busy = true;
this.statusText = "Генерация рельефа дна…";
const reliefPct = Math.max(1, Math.min(100, Number(this.reliefScalePct) || 20));
this.reliefScalePct = reliefPct;
this.pushLog(
`Подготовка: seed=${this.seed}, size=${this.sizeX}×${this.sizeY}, beams=${this.beamCount}`,
`Подготовка: seed=${this.seed}, неровности=${reliefPct}% ширины полосы, beams=${this.beamCount}`,
);
try {
const result = await api.mlePrepare({
seed: Number(this.seed) || 42,
sizeX: Number(this.sizeX) || 40,
sizeY: Number(this.sizeY) || 60,
reliefScalePct: reliefPct,
auvX: Number(this.auvX) || 0,
auvY: Number(this.auvY) || 0,
auvHeadingDeg: Number(this.auvHeadingDeg) || 0,
surveyLength: Number(this.surveyLength) || 40,
auvDepth: Number(this.auvDepth) || 2.5,
swathAngleDeg: Number(this.swathAngleDeg) || 90,
resX: 80,
resY: 120,
outputDir: String(this.outputDir || "mle_runs"),
settings: this.settingsSnapshot(),
});
if (result?.params?.sizeX != null) this.sizeX = result.params.sizeX;
if (result?.params?.sizeY != null) this.sizeY = result.params.sizeY;
this.scene = result;
this.runName = result.runName;
this.seafloorObjPath = result.seafloorObj;
+73 -8
View File
@@ -4,6 +4,9 @@ import {
filterPaletteGroupedModel,
reconstructionPaletteModel,
} from "@/catalog/pipelineUiCatalog";
import { createDebouncedSaver, loadUserSettings, saveUserSettings } from "@/utils/userSettings";
const STORAGE_KEY = "dottosurface.pipeline.settings.v1";
function configFromCards(stageCards) {
const preprocessPlugins = [];
@@ -76,16 +79,20 @@ function pipelineFingerprint(stageCards) {
}
export const usePipelineStore = defineStore("pipeline", {
state: () => ({
state: () => {
const saved = loadUserSettings(STORAGE_KEY, {});
return {
busy: false,
statusText: "Загрузите облако или сгенерируйте демо.",
stageCards: [],
selectedStageIndex: -1,
surfaceVisible: true,
demoSurfaceType: "Сфера",
selectedStageIndex: Number.isFinite(Number(saved.selectedStageIndex))
? Number(saved.selectedStageIndex)
: -1,
surfaceVisible: saved.surfaceVisible !== false,
demoSurfaceType: saved.demoSurfaceType || "Сфера",
demoSurfaceTypes: ["Сфера", "Тор", "Волна", "Дно реки + труба"],
wizardProfile: "general",
wizardGoal: "balanced",
wizardProfile: saved.wizardProfile || "general",
wizardGoal: saved.wizardGoal || "balanced",
presetItems: [],
metrics: {
inputPoints: 0,
@@ -111,7 +118,9 @@ export const usePipelineStore = defineStore("pipeline", {
stageMetaById: {},
/** Fingerprint of stageCards after last successful apply/run; null = never applied. */
appliedFingerprint: null,
}),
_savedStageCards: Array.isArray(saved.stageCards) ? saved.stageCards : null,
};
},
getters: {
selectedStage(state) {
if (state.selectedStageIndex < 0 || state.selectedStageIndex >= state.stageCards.length) {
@@ -137,6 +146,30 @@ export const usePipelineStore = defineStore("pipeline", {
},
},
actions: {
persistSettings() {
saveUserSettings(STORAGE_KEY, {
selectedStageIndex: this.selectedStageIndex,
surfaceVisible: this.surfaceVisible,
demoSurfaceType: this.demoSurfaceType,
wizardProfile: this.wizardProfile,
wizardGoal: this.wizardGoal,
stageCards: (this.stageCards || []).map((card) => ({
id: card.id,
title: card.title,
category: card.category,
family: card.family,
hint: card.hint,
defaults: card.defaults || "",
enabled: !!card.enabled,
})),
});
},
schedulePersist() {
if (!this._debouncedPersist) {
this._debouncedPersist = createDebouncedSaver(() => this.persistSettings(), 400);
}
this._debouncedPersist();
},
async bootstrap() {
const [presets, defaultConfig, catalog] = await Promise.all([
api.presets(),
@@ -147,8 +180,26 @@ export const usePipelineStore = defineStore("pipeline", {
(catalog.stageMeta || []).map((item) => [item.id, item]),
);
this.presetItems = presets;
this.stageCards = cardsFromConfig(defaultConfig, this.stageMetaById);
const savedCards = this._savedStageCards;
this._savedStageCards = null;
if (savedCards?.length) {
this.stageCards = savedCards.map((card) => {
const meta = metaForStage(card.id, this.stageMetaById);
return {
id: card.id,
title: card.title || meta.title || card.id,
category: card.category || meta.category || "Custom",
family: card.family || meta.family || "preprocess",
hint: card.hint || meta.hint || "",
defaults: card.defaults ?? meta.defaults ?? "",
enabled: card.enabled !== false,
};
});
} else {
this.stageCards = cardsFromConfig(defaultConfig, this.stageMetaById);
}
await this.validateChain();
this.persistSettings();
},
async validateChain() {
const result = await api.validateConfig(this.pipelineConfig);
@@ -156,9 +207,11 @@ export const usePipelineStore = defineStore("pipeline", {
this.recommendation = result.recommendation;
this.warningsList = result.warningsList || [];
if (result.stageCards) this.stageCards = result.stageCards;
this.schedulePersist();
},
setSelectedStageIndex(index) {
this.selectedStageIndex = index;
this.schedulePersist();
},
addStage(stageId) {
const meta = metaForStage(stageId, this.stageMetaById);
@@ -235,6 +288,7 @@ export const usePipelineStore = defineStore("pipeline", {
this.stageCards = cardsFromConfig(config, this.stageMetaById);
await this.validateChain();
this.statusText = `Пресет '${preset.title || presetId}' загружен. Нажмите 'Применить' для запуска.`;
this.schedulePersist();
},
async applyWizard() {
const result = await api.wizard(this.wizardProfile, this.wizardGoal);
@@ -243,12 +297,23 @@ export const usePipelineStore = defineStore("pipeline", {
this.recommendation = result.recommendation;
this.warningsList = result.warningsList || [];
this.statusText = `Wizard предложил пресет '${result.title}'.`;
this.schedulePersist();
},
setDemoSurfaceType(value) {
this.demoSurfaceType = value;
this.schedulePersist();
},
setSurfaceVisible(value) {
this.surfaceVisible = value;
this.schedulePersist();
},
setWizardProfile(value) {
this.wizardProfile = value;
this.schedulePersist();
},
setWizardGoal(value) {
this.wizardGoal = value;
this.schedulePersist();
},
async runPipeline({ useDemo = false, geometryFormat = "json" } = {}) {
const runWithDemo = useDemo || (this.usingDemoInput && !this.currentFile);
+31
View File
@@ -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);
};
}
+164 -5
View File
@@ -68,6 +68,27 @@ onMounted(async () => {
}
});
watch(
() => [
store.count,
store.seed,
store.outputDir,
store.modelSource,
store.modelPreset,
store.objectScale,
store.objectScaleIsMax,
store.beamCount,
store.lengthCount,
store.absentPct,
store.nearlyHiddenPct,
store.partialPct,
store.visiblePct,
],
() => {
store.schedulePersist();
},
);
async function onGenerate() {
try {
await store.generate();
@@ -94,6 +115,10 @@ function onModelFileChange(event) {
store.setModelFile(file);
}
function onModelSourceChange(event) {
store.setModelSource(event.target?.value);
}
function onHighlightClassChange(event) {
store.setHighlightClass(event.target?.value);
}
@@ -175,21 +200,39 @@ function runOptionLabel(run) {
<h2>Генератор датасета</h2>
<p class="hint">
Синтетические сцены эхолота для PointNet (сегментация: фон / object).
Целевой объект вершины выбранной .obj модели (класс 1).
Целевой объект предустановка «Трубопровод» или вершины загруженной .obj (класс 1).
Файлы: <code>Area_X_scene_XXXX.npy</code> + <code>.obj</code>.
</p>
<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
type="file"
accept=".obj,model/obj,text/plain"
:disabled="store.busy"
@change="onModelFileChange"
/>
<span class="ref-caption">
{{ store.modelFileName || "Файл не выбран" }}
</span>
</label>
<label class="field">
@@ -256,6 +299,62 @@ function runOptionLabel(run) {
<span>Число сцен</span>
<input v-model.number="store.count" type="number" min="1" max="5000" step="1" />
</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">
<span>Seed</span>
<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>
</section>
</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>
</template>
@@ -390,6 +505,35 @@ function runOptionLabel(run) {
gap: 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 {
width: auto;
max-width: none;
@@ -454,6 +598,21 @@ function runOptionLabel(run) {
font-size: 11px;
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 {
display: flex;
align-items: center;
+47 -13
View File
@@ -27,6 +27,9 @@ function applyLiveParams(fit = true) {
objectRotXDeg: store.objectRotXDeg,
objectRotYDeg: store.objectRotYDeg,
objectRotZDeg: store.objectRotZDeg,
objectSizeM: store.objectSizeM,
objectLengthM: store.objectLengthM,
objectKind: store.objectKind,
fitCamera: fit,
});
}
@@ -70,6 +73,8 @@ watch(
store.objectY,
store.objectZ,
store.objectSizeM,
store.objectLengthM,
store.objectKind,
store.objectRotXDeg,
store.objectRotYDeg,
store.objectRotZDeg,
@@ -79,8 +84,7 @@ watch(
store.speed,
store.surveyLength,
store.seed,
store.sizeX,
store.sizeY,
store.reliefScalePct,
store.outputDir,
],
() => {
@@ -97,6 +101,10 @@ function onObjectFile(event) {
store.setObjectFile(event.target?.files?.[0] || null);
}
function onObjectKindChange(event) {
store.setObjectKind(event.target?.value);
}
async function onPrepare() {
try {
const result = await store.prepare();
@@ -114,6 +122,8 @@ async function onPrepare() {
objectY: store.objectY,
objectZ: store.objectZ,
objectSizeM: store.objectSizeM,
objectLengthM: store.objectLengthM,
objectKind: store.objectKind,
objectRotXDeg: store.objectRotXDeg,
objectRotYDeg: store.objectRotYDeg,
objectRotZDeg: store.objectRotZDeg,
@@ -207,10 +217,22 @@ function onShotSurvey() {
<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>
<span>Объект на дне</span>
<select
:value="store.objectKind"
:disabled="store.busy || store.running"
@change="onObjectKindChange"
>
<option value="pipe">Трубопровод (предустановка)</option>
<option value="file">Загрузить .obj</option>
</select>
</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>
<div class="grid-2">
@@ -251,9 +273,13 @@ function onShotSurvey() {
<input v-model.number="store.objectZ" type="number" step="0.1" :disabled="store.running" />
</label>
<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" />
</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">
<span>Поворот X, °</span>
<input v-model.number="store.objectRotXDeg" type="number" step="1" :disabled="store.running" />
@@ -293,18 +319,25 @@ function onShotSurvey() {
</div>
<h3>Рельеф дна</h3>
<p class="hint">
При каждом «Подготовить» новый случайный рельеф в жёлтой полосе обзора эхолота.
Размер неровностей задаётся в % от ширины этой полосы.
</p>
<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" />
<span>Размер неровностей, % ширины полосы</span>
<input
v-model.number="store.reliefScalePct"
type="number"
min="1"
max="100"
step="1"
:disabled="store.busy || store.running"
/>
</label>
<label class="field">
<span>Каталог</span>
@@ -466,7 +499,8 @@ function onShotSurvey() {
}
.field input[type="number"],
.field input[type="text"],
.field input[type="file"] {
.field input[type="file"],
.field select {
width: 100%;
max-width: 100%;
min-width: 0;
+1
View File
@@ -9,6 +9,7 @@ import WizardPanel from "@/components/WizardPanel.vue";
const store = usePipelineStore();
onMounted(async () => {
store.$subscribe(() => store.schedulePersist());
if (!store.stageCards.length) {
try {
await store.bootstrap();