Реструктуризация проекта и генератор синтетических датасетов эхолота.
Перенесены backend/frontend/desktop/engine, добавлены вкладки конструктора сцен и генератора датасета с параметрами лучей и длины сетки рельефа, обновлены API и Docker-сборка. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
const API_BASE = "";
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, options);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(formatApiError(payload.detail, `Request failed: ${path}`));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function requestBlob(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, options);
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(formatApiError(payload.detail, `Request failed: ${path}`));
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const disposition = response.headers.get("Content-Disposition") || "";
|
||||
const utfMatch = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(disposition);
|
||||
const plainMatch = /filename="?([^";]+)"?/i.exec(disposition);
|
||||
let filename = "cloud.xyz";
|
||||
if (utfMatch?.[1]) {
|
||||
try {
|
||||
filename = decodeURIComponent(utfMatch[1]);
|
||||
} catch {
|
||||
filename = utfMatch[1];
|
||||
}
|
||||
} else if (plainMatch?.[1]) {
|
||||
filename = plainMatch[1];
|
||||
}
|
||||
return { blob, filename };
|
||||
}
|
||||
|
||||
function triggerDownload(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename || "cloud.xyz";
|
||||
link.rel = "noopener";
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||||
}
|
||||
|
||||
function exportFilename(name, format) {
|
||||
const allowed = new Set(["xyz", "ply", "obj", "npy"]);
|
||||
const ext = allowed.has(format) ? format : "xyz";
|
||||
const base = String(name || "cloud")
|
||||
.trim()
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/[^\w.\-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_|_$/g, "")
|
||||
.toLowerCase() || "cloud";
|
||||
return base.toLowerCase().endsWith(`.${ext}`) ? base : `${base}.${ext}`;
|
||||
}
|
||||
|
||||
function formatApiError(detail, fallback) {
|
||||
if (!detail) return fallback;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((item) => item.msg || JSON.stringify(item)).join("; ");
|
||||
}
|
||||
return String(detail);
|
||||
}
|
||||
|
||||
function pointsToXyz(points) {
|
||||
return points.map((p) => `${p[0]} ${p[1]} ${p[2]}`).join("\n") + (points.length ? "\n" : "");
|
||||
}
|
||||
|
||||
function pointsToPly(points) {
|
||||
const header = [
|
||||
"ply",
|
||||
"format ascii 1.0",
|
||||
`element vertex ${points.length}`,
|
||||
"property float x",
|
||||
"property float y",
|
||||
"property float z",
|
||||
"end_header",
|
||||
].join("\n");
|
||||
const body = points.map((p) => `${p[0]} ${p[1]} ${p[2]}`).join("\n");
|
||||
return `${header}\n${body}${points.length ? "\n" : ""}`;
|
||||
}
|
||||
|
||||
function pointsToObj(points, objectName = "cloud") {
|
||||
const safe = String(objectName || "cloud").replace(/[^\w\-]+/g, "_") || "cloud";
|
||||
const lines = [`# DotsToSurface point cloud (${points.length} vertices)`, `o ${safe}`];
|
||||
for (const p of points) {
|
||||
lines.push(`v ${p[0]} ${p[1]} ${p[2]}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function parseObjPoints(text) {
|
||||
const points = [];
|
||||
for (const raw of String(text || "").split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
if (!/^v\s/i.test(line)) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
const x = Number(parts[1]);
|
||||
const y = Number(parts[2]);
|
||||
const z = Number(parts[3]);
|
||||
if (![x, y, z].every(Number.isFinite)) continue;
|
||||
points.push([x, y, z]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function parseXyzPoints(text) {
|
||||
const points = [];
|
||||
for (const raw of String(text || "").split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#") || line.startsWith("//")) continue;
|
||||
const parts = line.split(/[\s,;]+/).filter(Boolean);
|
||||
if (parts.length < 3) continue;
|
||||
const x = Number(parts[0]);
|
||||
const y = Number(parts[1]);
|
||||
const z = Number(parts[2]);
|
||||
if (![x, y, z].every(Number.isFinite)) continue;
|
||||
points.push([x, y, z]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function parsePlyPoints(text) {
|
||||
const lines = String(text || "").split(/\r?\n/);
|
||||
if (!lines.length || !/^ply\b/i.test(lines[0].trim())) {
|
||||
return parseXyzPoints(text);
|
||||
}
|
||||
let i = 1;
|
||||
let vertexCount = 0;
|
||||
let format = "ascii";
|
||||
const props = [];
|
||||
let inVertexElement = false;
|
||||
for (; i < lines.length; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.startsWith("format ")) {
|
||||
format = lower.split(/\s+/)[1] || "ascii";
|
||||
continue;
|
||||
}
|
||||
if (lower.startsWith("element vertex")) {
|
||||
inVertexElement = true;
|
||||
vertexCount = Number(line.split(/\s+/)[2]) || 0;
|
||||
props.length = 0;
|
||||
continue;
|
||||
}
|
||||
if (lower.startsWith("element ")) {
|
||||
inVertexElement = false;
|
||||
continue;
|
||||
}
|
||||
if (inVertexElement && lower.startsWith("property ")) {
|
||||
if (lower.includes("list")) continue;
|
||||
const tokens = line.split(/\s+/);
|
||||
props.push(tokens[tokens.length - 1].toLowerCase());
|
||||
continue;
|
||||
}
|
||||
if (/^end_header\b/i.test(lower)) {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!format.startsWith("ascii")) {
|
||||
throw new Error("Поддерживается только ASCII PLY (не binary).");
|
||||
}
|
||||
const ix = props.indexOf("x");
|
||||
const iy = props.indexOf("y");
|
||||
const iz = props.indexOf("z");
|
||||
if (ix < 0 || iy < 0 || iz < 0) {
|
||||
// Some PLY dumps put only xyz numbers after header without named props tracked — try sequential.
|
||||
return parseXyzPoints(lines.slice(i).join("\n"));
|
||||
}
|
||||
const points = [];
|
||||
const limit = vertexCount > 0 ? Math.min(lines.length, i + vertexCount) : lines.length;
|
||||
for (; i < limit; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
const parts = line.split(/\s+/);
|
||||
const x = Number(parts[ix]);
|
||||
const y = Number(parts[iy]);
|
||||
const z = Number(parts[iz]);
|
||||
if (![x, y, z].every(Number.isFinite)) continue;
|
||||
points.push([x, y, z]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function detectCloudFormat(filename, text) {
|
||||
const ext = String(filename || "").toLowerCase().split(".").pop();
|
||||
if (ext === "obj") return "obj";
|
||||
if (ext === "ply") return "ply";
|
||||
if (ext === "xyz" || ext === "txt" || ext === "csv") return "xyz";
|
||||
const head = String(text || "").slice(0, 200).trim().toLowerCase();
|
||||
if (head.startsWith("ply")) return "ply";
|
||||
if (/(^|\n)\s*v\s+[-+]?\d/i.test(String(text || "").slice(0, 2000))) return "obj";
|
||||
return "xyz";
|
||||
}
|
||||
|
||||
function parseCloudPoints(text, filename = "") {
|
||||
const format = detectCloudFormat(filename, text);
|
||||
let points;
|
||||
if (format === "obj") points = parseObjPoints(text);
|
||||
else if (format === "ply") points = parsePlyPoints(text);
|
||||
else points = parseXyzPoints(text);
|
||||
return { format, points };
|
||||
}
|
||||
|
||||
function downloadPointsLocally(points, format, filename) {
|
||||
let text;
|
||||
if (format === "ply") text = pointsToPly(points);
|
||||
else if (format === "obj") text = pointsToObj(points, filename.replace(/\.\w+$/, ""));
|
||||
else text = pointsToXyz(points);
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
triggerDownload(blob, exportFilename(filename, format));
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => request("/api/health"),
|
||||
catalog: () => request("/api/catalog"),
|
||||
presets: () => request("/api/presets"),
|
||||
builtinPresets: () => request("/api/builtin-presets"),
|
||||
defaultConfig: () => request("/api/default-config"),
|
||||
stageDefaults: (stageId) => request(`/api/stage-defaults/${encodeURIComponent(stageId)}`),
|
||||
validateConfig: (config) =>
|
||||
request("/api/validate-config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ config }),
|
||||
}),
|
||||
wizard: (wizardProfile, wizardGoal) =>
|
||||
request("/api/wizard", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ wizardProfile, wizardGoal }),
|
||||
}),
|
||||
demoTypes: () => request("/api/demo/types"),
|
||||
demo: (surfaceType) => request(`/api/demo?surfaceType=${encodeURIComponent(surfaceType)}`),
|
||||
userPresets: () => request("/api/user-presets"),
|
||||
saveUserPreset: (preset) =>
|
||||
request("/api/user-presets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(preset),
|
||||
}),
|
||||
runPipeline: ({ file, config, presetId, demoSurface, geometryFormat = "json" }) => {
|
||||
const formData = new FormData();
|
||||
if (file) formData.append("file", file);
|
||||
if (presetId) formData.append("preset_id", presetId);
|
||||
if (config) formData.append("config_json", JSON.stringify(config));
|
||||
if (demoSurface) formData.append("demo_surface", demoSurface);
|
||||
formData.append("geometry_format", geometryFormat);
|
||||
return request("/api/run", { method: "POST", body: formData });
|
||||
},
|
||||
fetchGeometry: async (workId) => {
|
||||
const response = await fetch(`/api/geometry/${workId}`);
|
||||
if (!response.ok) throw new Error("Failed to load geometry");
|
||||
return response.arrayBuffer();
|
||||
},
|
||||
generatorCatalog: () => request("/api/generator/catalog"),
|
||||
generatorLayer: (kind, type, params) =>
|
||||
request("/api/generator/layer", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind, type, params }),
|
||||
}),
|
||||
generatorResolveIntersections: (payload) =>
|
||||
request("/api/generator/resolve-intersections", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
generatorExport: async ({ points, layers, format = "xyz", filename, classLabel }) => {
|
||||
const safeName = exportFilename(filename, format);
|
||||
// .npy is binary PointNet format — always via backend.
|
||||
if (format !== "npy" && Array.isArray(points) && points.length) {
|
||||
downloadPointsLocally(points, format, safeName);
|
||||
return;
|
||||
}
|
||||
const { blob, filename: suggested } = await requestBlob("/api/generator/export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ points, layers, format, filename: safeName, classLabel }),
|
||||
});
|
||||
triggerDownload(blob, safeName || suggested);
|
||||
},
|
||||
datasetGenerate: ({
|
||||
count = 5,
|
||||
seed = 42,
|
||||
outputDir = "sonar_dataset",
|
||||
objectScale = 1,
|
||||
beamCount = 45,
|
||||
lengthCount = 45,
|
||||
modelFile,
|
||||
} = {}) => {
|
||||
if (!modelFile) {
|
||||
return Promise.reject(new Error("Выберите файл модели .obj"));
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("count", String(count));
|
||||
formData.append("seed", String(seed));
|
||||
formData.append("outputDir", outputDir || "sonar_dataset");
|
||||
formData.append("objectScale", String(objectScale ?? 1));
|
||||
formData.append("beamCount", String(beamCount ?? 45));
|
||||
formData.append("lengthCount", String(lengthCount ?? 45));
|
||||
formData.append("model", modelFile, modelFile.name || "model.obj");
|
||||
return request("/api/dataset/generate", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
datasetPreview: ({ stem, outputDir = "sonar_dataset", maxPoints = 25000 } = {}) =>
|
||||
request("/api/dataset/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ stem, outputDir, maxPoints }),
|
||||
}),
|
||||
};
|
||||
|
||||
export { exportFilename, parseObjPoints, parseXyzPoints, parsePlyPoints, parseCloudPoints };
|
||||
Reference in New Issue
Block a user