Files
DotsToSirface/frontend/web/src/api/client.js
T
2026-07-17 15:06:51 +03:00

328 lines
11 KiB
JavaScript

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,
objectScaleIsMax = false,
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("objectScaleIsMax", objectScaleIsMax ? "true" : "false");
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 };