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 };