Реструктуризация проекта и генератор синтетических датасетов эхолота.
Перенесены backend/frontend/desktop/engine, добавлены вкладки конструктора сцен и генератора датасета с параметрами лучей и длины сетки рельефа, обновлены API и Docker-сборка. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||
import { api } from "@/api/client";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
|
||||
const route = useRoute();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const healthText = ref("Checking API...");
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const health = await api.health();
|
||||
healthText.value = health.binaryExists === "True" || health.binaryExists === true
|
||||
? "API online, pipeline binary ready"
|
||||
: "API online, binary missing";
|
||||
} catch (error) {
|
||||
healthText.value = `API error: ${error.message}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-root">
|
||||
<header class="header">
|
||||
<div class="header-brand">
|
||||
<h1>DotsToSurface</h1>
|
||||
<nav class="nav-tabs" aria-label="Разделы">
|
||||
<RouterLink
|
||||
class="nav-tab"
|
||||
:class="{ active: route.path === '/' }"
|
||||
to="/"
|
||||
>
|
||||
Пайплайн
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
class="nav-tab"
|
||||
:class="{ active: route.path.startsWith('/generator') }"
|
||||
to="/generator"
|
||||
>
|
||||
Генератор
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
class="nav-tab"
|
||||
:class="{ active: route.path.startsWith('/dataset') }"
|
||||
to="/dataset"
|
||||
>
|
||||
Генератор Датасета
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="theme-toggle" @click="toggleTheme">
|
||||
{{ theme === "light" ? "Тёмная тема" : "Светлая тема" }}
|
||||
</button>
|
||||
<div class="health">{{ healthText }}</div>
|
||||
</div>
|
||||
</header>
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
background: var(--header-bg);
|
||||
gap: 16px;
|
||||
}
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
.header h1 { margin: 0; font-size: 20px; white-space: nowrap; }
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.nav-tab {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
color: var(--muted-text);
|
||||
font-size: 14px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.nav-tab:hover {
|
||||
color: var(--control-text);
|
||||
background: var(--button-bg);
|
||||
}
|
||||
.nav-tab.active {
|
||||
color: var(--control-text);
|
||||
border-color: var(--chain-selected-border);
|
||||
background: var(--chain-enabled-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.header-actions { display: flex; gap: 10px; align-items: center; flex-shrink: 0; }
|
||||
.theme-toggle { font-size: 13px; padding: 6px 10px; }
|
||||
.health {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--health-bg);
|
||||
color: var(--health-text);
|
||||
}
|
||||
</style>
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,469 @@
|
||||
|
||||
function inferParamType(valueText) {
|
||||
var v = (valueText === undefined || valueText === null) ? "" : String(valueText).trim()
|
||||
if (v === "true" || v === "false")
|
||||
return "bool"
|
||||
if (/^-?\d+$/.test(v))
|
||||
return "int"
|
||||
if (/^-?(?:\d+\.\d*|\d*\.\d+)$/.test(v))
|
||||
return "float"
|
||||
return "string"
|
||||
}
|
||||
|
||||
function parseDefaults(defaultsText) {
|
||||
var out = []
|
||||
if (!defaultsText)
|
||||
return out
|
||||
var chunks = String(defaultsText).split(",")
|
||||
for (var i = 0; i < chunks.length; ++i) {
|
||||
var chunk = chunks[i].trim()
|
||||
if (!chunk)
|
||||
continue
|
||||
var eq = chunk.indexOf("=")
|
||||
var key = eq >= 0 ? chunk.slice(0, eq).trim() : chunk
|
||||
var value = eq >= 0 ? chunk.slice(eq + 1).trim() : ""
|
||||
out.push({ key: key, value: value, kind: inferParamType(value) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function serializeParams(items) {
|
||||
var parts = []
|
||||
for (var i = 0; i < items.length; ++i) {
|
||||
var p = items[i]
|
||||
parts.push(p.key + "=" + p.value)
|
||||
}
|
||||
return parts.join(",")
|
||||
}
|
||||
|
||||
function paramDescription(key) {
|
||||
if (key === "minCluster")
|
||||
return "Минимальный размер кластера в точках."
|
||||
if (key === "targetPoints")
|
||||
return "Целевое количество точек после прореживания."
|
||||
if (key === "leaf")
|
||||
return "Размер вокселя для Voxel Grid."
|
||||
if (key === "meanK")
|
||||
return "Число соседей для статистической оценки."
|
||||
if (key === "stddev")
|
||||
return "Порог отклонения для удаления выбросов."
|
||||
if (key === "radius")
|
||||
return "Радиус поиска соседей."
|
||||
if (key === "minNeighbors")
|
||||
return "Минимум соседей, чтобы точка считалась валидной."
|
||||
if (key === "threshold")
|
||||
return "Порог отклонения точки от геометрической модели."
|
||||
if (key === "shadowThreshold")
|
||||
return "Порог для удаления теневых точек по нормалям."
|
||||
if (key === "resolution")
|
||||
return "Размер ячейки 2D-сетки для фильтра GridMinimum."
|
||||
if (key === "sample")
|
||||
return "Количество точек, которое нужно оставить после выборки."
|
||||
if (key === "axis")
|
||||
return "Ось фильтрации: x, y или z."
|
||||
if (key === "min" || key === "max")
|
||||
return "Граница диапазона для фильтра PassThrough."
|
||||
if (key === "minX" || key === "minY" || key === "minZ" || key === "maxX" || key === "maxY" || key === "maxZ")
|
||||
return "Границы CropBox/CropHull по соответствующим осям."
|
||||
if (key === "near" || key === "far")
|
||||
return "Ближняя/дальняя граница фрустума."
|
||||
if (key === "hfov" || key === "vfov")
|
||||
return "Горизонтальный/вертикальный угол обзора (в градусах)."
|
||||
if (key === "a" || key === "b" || key === "c" || key === "d")
|
||||
return "Коэффициенты плоскости ax+by+cz+d=0."
|
||||
if (key === "keepPositive")
|
||||
return "Оставлять ли точки на положительной стороне плоскости."
|
||||
if (key === "zMin" || key === "zMax")
|
||||
return "Допустимый диапазон координаты Z для conditional-фильтра."
|
||||
if (key === "nth")
|
||||
return "Оставлять каждую N-ю точку."
|
||||
if (key === "radiusMax")
|
||||
return "Максимальный радиус точки от начала координат."
|
||||
if (key === "sigmaS" || key === "sigmaR")
|
||||
return "Параметры bilateral-фильтра (пространство/интенсивность)."
|
||||
if (key === "sigma" || key === "kernel")
|
||||
return "Параметры гауссова ядра свертки."
|
||||
if (key === "iterations")
|
||||
return "Число итераций уточнения."
|
||||
if (key === "minHits")
|
||||
return "Минимальная заполненность вокселя для прохождения фильтра."
|
||||
if (key === "neighborRadiusScale")
|
||||
return "Масштаб радиуса поиска соседей для fallback-реконструкции."
|
||||
if (key === "runEveryNthFrame")
|
||||
return "Запуск реконструкции на каждом N-м кадре."
|
||||
if (key === "searchRadius")
|
||||
return "Радиус поиска соседей для greedy triangulation."
|
||||
if (key === "mu")
|
||||
return "Коэффициент плотности соседей для greedy triangulation."
|
||||
if (key === "maxNearest")
|
||||
return "Максимум соседей для greedy triangulation."
|
||||
if (key === "maxSurfaceAngle")
|
||||
return "Максимальный угол поверхности в радианах."
|
||||
if (key === "poissonDepth")
|
||||
return "Глубина октодерева для Poisson реконструкции."
|
||||
if (key === "samplesPerNode")
|
||||
return "Число выборок на узел для сглаживания Poisson."
|
||||
return "Параметр этапа обработки."
|
||||
}
|
||||
|
||||
function reconstructionTitle(id) {
|
||||
if (id === "surface_fallback")
|
||||
return "Fallback Surface"
|
||||
if (id === "pcl_greedy_triangulation")
|
||||
return "PCL Greedy Triangulation"
|
||||
if (id === "pcl_poisson_reconstruction")
|
||||
return "PCL Poisson Reconstruction"
|
||||
return id
|
||||
}
|
||||
|
||||
function phaseModel() {
|
||||
return [
|
||||
{
|
||||
id: "crop",
|
||||
title: "Обрезка",
|
||||
hint: "Ограничение области интереса и удаление лишних фрагментов.",
|
||||
accent: "#88d1ff",
|
||||
filterBg: "#263e55",
|
||||
filterBorder: "#4b7598"
|
||||
},
|
||||
{
|
||||
id: "nan_preclean",
|
||||
title: "NaN (предочистка)",
|
||||
hint: "Удаление NaN сразу после загрузки/обрезки, иначе статистические фильтры работают нестабильно.",
|
||||
accent: "#8fe8ff",
|
||||
filterBg: "#1f4450",
|
||||
filterBorder: "#3f8394"
|
||||
},
|
||||
{
|
||||
id: "conditions_indexes",
|
||||
title: "Условия/Индексы",
|
||||
hint: "Отбор точек по полям, диапазонам и индексным маскам.",
|
||||
accent: "#9fd7ff",
|
||||
filterBg: "#24405a",
|
||||
filterBorder: "#4f7aa1"
|
||||
},
|
||||
{
|
||||
id: "noise",
|
||||
title: "Шум",
|
||||
hint: "Удаление выбросов и нестабильных точек перед геометрией.",
|
||||
accent: "#a4f4b9",
|
||||
filterBg: "#244534",
|
||||
filterBorder: "#4b8f68"
|
||||
},
|
||||
{
|
||||
id: "morphology",
|
||||
title: "Морфология",
|
||||
hint: "Геометрические операции локальной структуры облака.",
|
||||
accent: "#9cf5d1",
|
||||
filterBg: "#1f4a3e",
|
||||
filterBorder: "#4b8f7d"
|
||||
},
|
||||
{
|
||||
id: "downsample",
|
||||
title: "Прореживание",
|
||||
hint: "Снижение плотности облака для скорости и устойчивости.",
|
||||
accent: "#ffcb8a",
|
||||
filterBg: "#4a3724",
|
||||
filterBorder: "#8e6f47"
|
||||
},
|
||||
{
|
||||
id: "smoothing",
|
||||
title: "Сглаживание",
|
||||
hint: "Снижение локального шума перед расчетом нормалей и реконструкцией.",
|
||||
accent: "#ffd892",
|
||||
filterBg: "#4e3a22",
|
||||
filterBorder: "#917349"
|
||||
},
|
||||
{
|
||||
id: "normals",
|
||||
title: "Нормали",
|
||||
hint: "Подготовка нормалей для продвинутой реконструкции (этап зарезервирован).",
|
||||
accent: "#d2b7ff",
|
||||
filterBg: "#3e3155",
|
||||
filterBorder: "#6f5a95"
|
||||
},
|
||||
{
|
||||
id: "reconstruction",
|
||||
title: "Реконструкция",
|
||||
hint: "Построение поверхности по подготовленному облаку точек.",
|
||||
accent: "#ff9dc4",
|
||||
filterBg: "#4b2f40",
|
||||
filterBorder: "#8d5a75"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function filterDefinitions() {
|
||||
return [
|
||||
{
|
||||
title: "Крупнейший кластер",
|
||||
idValue: "keep_largest_cluster",
|
||||
phaseId: "crop",
|
||||
hint: "Оставляет основной объект и отбрасывает изолированные фрагменты."
|
||||
},
|
||||
{
|
||||
title: "Remove NaN Points",
|
||||
idValue: "pcl_remove_nan",
|
||||
phaseId: "nan_preclean",
|
||||
family: "preprocess",
|
||||
hint: "Удаляет NaN/Inf после загрузки и обрезки, до статистических фильтров."
|
||||
},
|
||||
{
|
||||
title: "Remove NaN Normals",
|
||||
idValue: "pcl_remove_nan_normals",
|
||||
phaseId: "nan_preclean",
|
||||
family: "preprocess",
|
||||
hint: "Удаляет невалидные нормали (в текущем формате — невалидную геометрию)."
|
||||
},
|
||||
{
|
||||
title: "PassThrough",
|
||||
idValue: "pcl_pass_through",
|
||||
phaseId: "crop",
|
||||
hint: "Фильтрует точки по диапазону выбранной оси."
|
||||
},
|
||||
{
|
||||
title: "CropBox",
|
||||
idValue: "pcl_crop_box",
|
||||
phaseId: "crop",
|
||||
hint: "Ограничивает облако заданным 3D-параллелепипедом."
|
||||
},
|
||||
{
|
||||
title: "CropHull",
|
||||
idValue: "pcl_crop_hull",
|
||||
phaseId: "crop",
|
||||
hint: "Обрезка по области интереса (в текущей версии через box-границы)."
|
||||
},
|
||||
{
|
||||
title: "Frustum Culling",
|
||||
idValue: "pcl_frustum_culling",
|
||||
phaseId: "crop",
|
||||
hint: "Оставляет точки в пирамиде видимости камеры."
|
||||
},
|
||||
{
|
||||
title: "PlaneClipper3D",
|
||||
idValue: "pcl_plane_clipper_3d",
|
||||
phaseId: "crop",
|
||||
hint: "Отсекает точки по уравнению плоскости."
|
||||
},
|
||||
{
|
||||
title: "Conditional Removal",
|
||||
idValue: "pcl_conditional_removal",
|
||||
phaseId: "conditions_indexes",
|
||||
hint: "Удаляет точки по логическому условию (диапазон Z)."
|
||||
},
|
||||
{
|
||||
title: "Extract Indices",
|
||||
idValue: "pcl_extract_indices",
|
||||
phaseId: "conditions_indexes",
|
||||
hint: "Извлекает точки по индексной маске (каждая N-я)."
|
||||
},
|
||||
{
|
||||
title: "Functor Filter",
|
||||
idValue: "pcl_functor_filter",
|
||||
phaseId: "conditions_indexes",
|
||||
hint: "Пользовательский предикат (радиус + проверка валидности)."
|
||||
},
|
||||
{
|
||||
title: "ProjectInliers",
|
||||
idValue: "pcl_project_inliers",
|
||||
phaseId: "normals",
|
||||
hint: "Проецирует точки на геометрическую модель (плоскость)."
|
||||
},
|
||||
{
|
||||
title: "Normal Refinement",
|
||||
idValue: "pcl_normal_refinement",
|
||||
phaseId: "normals",
|
||||
hint: "Уточняет локальную геометрию по соседям."
|
||||
},
|
||||
{
|
||||
title: "Статистическая фильтрация",
|
||||
idValue: "pcl_statistical_outlier",
|
||||
phaseId: "noise",
|
||||
hint: "Удаляет выбросы на основе распределения расстояний до соседей."
|
||||
},
|
||||
{
|
||||
title: "Радиусная фильтрация",
|
||||
idValue: "pcl_radius_outlier",
|
||||
phaseId: "noise",
|
||||
hint: "Удаляет точки с недостаточным числом соседей в заданном радиусе."
|
||||
},
|
||||
{
|
||||
title: "Model Outlier Removal",
|
||||
idValue: "pcl_model_outlier",
|
||||
phaseId: "noise",
|
||||
hint: "Удаляет точки, отклоняющиеся от геометрической модели."
|
||||
},
|
||||
{
|
||||
title: "Shadow Points Removal",
|
||||
idValue: "pcl_shadow_points",
|
||||
phaseId: "noise",
|
||||
hint: "Удаляет теневые точки на основе нормалей поверхности."
|
||||
},
|
||||
{
|
||||
title: "Approximate Voxel Grid",
|
||||
idValue: "pcl_approximate_voxel_grid",
|
||||
phaseId: "downsample",
|
||||
hint: "Ускоренное воксельное прореживание для больших облаков."
|
||||
},
|
||||
{
|
||||
title: "Voxel Grid Label",
|
||||
idValue: "pcl_voxel_grid_label",
|
||||
phaseId: "downsample",
|
||||
hint: "Воксельное прореживание с поддержкой меток."
|
||||
},
|
||||
{
|
||||
title: "Voxel Grid Covariance",
|
||||
idValue: "pcl_voxel_grid_covariance",
|
||||
phaseId: "downsample",
|
||||
hint: "Воксельная сетка с ковариациями (для NDT-пайплайнов)."
|
||||
},
|
||||
{
|
||||
title: "Grid Minimum",
|
||||
idValue: "pcl_grid_minimum",
|
||||
phaseId: "downsample",
|
||||
hint: "Оставляет точку с минимальным Z в каждой ячейке."
|
||||
},
|
||||
{
|
||||
title: "Farthest Point Sampling",
|
||||
idValue: "pcl_farthest_point_sampling",
|
||||
phaseId: "downsample",
|
||||
hint: "Выбирает наиболее удаленные друг от друга точки."
|
||||
},
|
||||
{
|
||||
title: "Normal Space Sampling",
|
||||
idValue: "pcl_normal_space_sampling",
|
||||
phaseId: "downsample",
|
||||
hint: "Равномерная выборка в пространстве нормалей."
|
||||
},
|
||||
{
|
||||
title: "Sampling Surface Normal",
|
||||
idValue: "pcl_sampling_surface_normal",
|
||||
phaseId: "downsample",
|
||||
hint: "Выборка точек на основе нормалей поверхности."
|
||||
},
|
||||
{
|
||||
title: "Bilateral Filter",
|
||||
idValue: "pcl_bilateral_filter",
|
||||
phaseId: "smoothing",
|
||||
hint: "Двустороннее сглаживание с сохранением границ."
|
||||
},
|
||||
{
|
||||
title: "Fast Bilateral Filter",
|
||||
idValue: "pcl_fast_bilateral_filter",
|
||||
phaseId: "smoothing",
|
||||
hint: "Быстрое двустороннее сглаживание."
|
||||
},
|
||||
{
|
||||
title: "Fast Bilateral Filter OMP",
|
||||
idValue: "pcl_fast_bilateral_filter_omp",
|
||||
phaseId: "smoothing",
|
||||
hint: "Параллельная версия bilateral-фильтра."
|
||||
},
|
||||
{
|
||||
title: "Convolution",
|
||||
idValue: "pcl_convolution",
|
||||
phaseId: "smoothing",
|
||||
hint: "Свертка облака точек с ядром."
|
||||
},
|
||||
{
|
||||
title: "Gaussian Kernel",
|
||||
idValue: "pcl_gaussian_kernel",
|
||||
phaseId: "smoothing",
|
||||
hint: "Гауссово ядро свертки."
|
||||
},
|
||||
{
|
||||
title: "Gaussian Kernel RGB",
|
||||
idValue: "pcl_gaussian_kernel_rgb",
|
||||
phaseId: "smoothing",
|
||||
hint: "Гауссово ядро с RGB-ориентированной семантикой."
|
||||
},
|
||||
{
|
||||
title: "VoxelGrid Occlusion Estimation",
|
||||
idValue: "pcl_voxel_grid_occlusion",
|
||||
phaseId: "morphology",
|
||||
hint: "Оценка окклюзии через заполненность вокселей."
|
||||
},
|
||||
{
|
||||
title: "Fallback Surface",
|
||||
idValue: "surface_fallback",
|
||||
phaseId: "reconstruction",
|
||||
family: "reconstruction",
|
||||
hint: "Базовая реконструкция поверхности по облаку точек."
|
||||
},
|
||||
{
|
||||
title: "PCL Greedy Triangulation",
|
||||
idValue: "pcl_greedy_triangulation",
|
||||
phaseId: "reconstruction",
|
||||
family: "reconstruction",
|
||||
hint: "Жадная триангуляция с параметрами радиуса и углов."
|
||||
},
|
||||
{
|
||||
title: "PCL Poisson Reconstruction",
|
||||
idValue: "pcl_poisson_reconstruction",
|
||||
phaseId: "reconstruction",
|
||||
family: "reconstruction",
|
||||
hint: "Реконструкция поверхности методом Poisson из libpcl_surface."
|
||||
},
|
||||
{
|
||||
title: "Прореживание плотности",
|
||||
idValue: "downsample_dense",
|
||||
phaseId: "downsample",
|
||||
hint: "Снижает число точек для ускорения пайплайна на плотных облаках."
|
||||
},
|
||||
{
|
||||
title: "PCL Voxel Grid",
|
||||
idValue: "pcl_voxel_grid",
|
||||
phaseId: "downsample",
|
||||
hint: "Равномерно прореживает облако с помощью воксельной сетки."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function filterPaletteModel() {
|
||||
return filterDefinitions()
|
||||
}
|
||||
|
||||
function filterPaletteGroupedModel() {
|
||||
var phases = phaseModel()
|
||||
var filters = filterDefinitions()
|
||||
var grouped = []
|
||||
for (var p = 0; p < phases.length; ++p) {
|
||||
var phase = phases[p]
|
||||
var items = []
|
||||
for (var i = 0; i < filters.length; ++i) {
|
||||
if (filters[i].phaseId === phase.id)
|
||||
items.push(filters[i])
|
||||
}
|
||||
grouped.push({
|
||||
phaseId: phase.id,
|
||||
phaseTitle: phase.title,
|
||||
phaseHint: phase.hint,
|
||||
phaseAccent: phase.accent,
|
||||
phaseFilterBg: phase.filterBg,
|
||||
phaseFilterBorder: phase.filterBorder,
|
||||
items: items
|
||||
})
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
function reconstructionPaletteModel() {
|
||||
return [
|
||||
{ title: "Fallback Surface", idValue: "surface_fallback" },
|
||||
{ title: "PCL Greedy Triangulation", idValue: "pcl_greedy_triangulation" },
|
||||
{ title: "PCL Poisson Reconstruction", idValue: "pcl_poisson_reconstruction" }
|
||||
]
|
||||
}
|
||||
|
||||
export {
|
||||
inferParamType,
|
||||
parseDefaults,
|
||||
serializeParams,
|
||||
paramDescription,
|
||||
reconstructionTitle,
|
||||
phaseModel,
|
||||
filterDefinitions,
|
||||
filterPaletteModel,
|
||||
filterPaletteGroupedModel,
|
||||
reconstructionPaletteModel,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
export const panelBg = "#141821";
|
||||
export const cardBg = "#111a2c";
|
||||
export const cardBorder = "#33486f";
|
||||
|
||||
export const chainSelectedBorder = "#6ea8ff";
|
||||
export const chainIdleBorder = "#384866";
|
||||
export const chainEnabledBg = "#1f2a3d";
|
||||
export const chainDisabledBg = "#23272f";
|
||||
export const chainDragOverlay = "#39507d";
|
||||
export const chainDragHandle = "#9fb4da";
|
||||
|
||||
export const reconstructionBg = "#2d2438";
|
||||
export const reconstructionBorder = "#625481";
|
||||
export const reconstructionAccent = "#d9c8ff";
|
||||
export const reconstructionText = "#efe7ff";
|
||||
export const reconstructionSubtext = "#c3b2e8";
|
||||
|
||||
export const paletteFilterBg = "#26344f";
|
||||
export const paletteFilterBorder = "#3d547d";
|
||||
export const paletteReconBg = "#3a2f44";
|
||||
export const paletteReconBorder = "#625481";
|
||||
export const paletteHeader = "#d6e3ff";
|
||||
export const paletteFilterText = "#eaf0ff";
|
||||
export const paletteReconText = "#efe7ff";
|
||||
|
||||
export const summaryPrimaryText = "#eaf0ff";
|
||||
export const summarySecondaryText = "#cde0ff";
|
||||
export const summaryRecommendation = "#9ec3ff";
|
||||
export const summaryDetailText = "#b8cff8";
|
||||
|
||||
export const dialogHintText = "#c7d6f3";
|
||||
export const dialogDescriptionText = "#aebfde";
|
||||
export const dialogSectionText = "#dce7ff";
|
||||
export const dialogBg = "#1a2234";
|
||||
export const dialogBorder = "#3a4f78";
|
||||
|
||||
export const controlBg = "#23324d";
|
||||
export const controlBorder = "#4a6494";
|
||||
export const controlText = "#eaf0ff";
|
||||
export const controlPlaceholder = "#9cb0d6";
|
||||
export const controlHoverBg = "#2a3b5a";
|
||||
|
||||
export const buttonBg = "#2c3f61";
|
||||
export const buttonBorder = "#5574a9";
|
||||
export const buttonText = "#eef4ff";
|
||||
export const buttonHoverBg = "#35507a";
|
||||
export const buttonPressedBg = "#273b5c";
|
||||
|
||||
export const primaryButtonBg = "#ffb020";
|
||||
export const primaryButtonBorder = "#ffd27a";
|
||||
export const primaryButtonText = "#142033";
|
||||
export const primaryButtonHoverBg = "#ffc247";
|
||||
export const primaryButtonPressedBg = "#e39b0f";
|
||||
|
||||
export const spacingXs = 4;
|
||||
export const spacingSm = 6;
|
||||
export const spacingMd = 8;
|
||||
export const spacingLg = 10;
|
||||
export const radiusSm = 6;
|
||||
export const radiusMd = 8;
|
||||
export const compactControlHeight = 28;
|
||||
|
||||
export const dashboardSurfaceComboWidth = 170;
|
||||
export const dashboardMetricsHeight = 148;
|
||||
export const dashboardBottomMargin = 8;
|
||||
export const palettePanelWidth = 260;
|
||||
|
||||
export const stageRowHeight = 48;
|
||||
export const stageHandleWidth = 16;
|
||||
export const reconstructionBadgeWidth = 28;
|
||||
export const paletteItemHeight = 30;
|
||||
|
||||
export const dialogWidth = 390;
|
||||
export const dialogLabelWidth = 128;
|
||||
export const dialogResetButtonWidth = 168;
|
||||
export const dialogOkButtonWidth = 76;
|
||||
export const dialogContentSpacing = 4;
|
||||
export const dialogRowSpacing = 6;
|
||||
|
||||
export const fontXs = 10;
|
||||
export const fontSm = 11;
|
||||
export const fontMd = 12;
|
||||
export const fontLg = 14;
|
||||
|
||||
export const theme = {
|
||||
panelBg,
|
||||
cardBg,
|
||||
cardBorder,
|
||||
chainSelectedBorder,
|
||||
chainIdleBorder,
|
||||
chainEnabledBg,
|
||||
chainDisabledBg,
|
||||
chainDragOverlay,
|
||||
chainDragHandle,
|
||||
reconstructionBg,
|
||||
reconstructionBorder,
|
||||
reconstructionAccent,
|
||||
reconstructionText,
|
||||
reconstructionSubtext,
|
||||
paletteFilterBg,
|
||||
paletteFilterBorder,
|
||||
paletteReconBg,
|
||||
paletteReconBorder,
|
||||
paletteHeader,
|
||||
paletteFilterText,
|
||||
paletteReconText,
|
||||
summaryPrimaryText,
|
||||
summarySecondaryText,
|
||||
summaryRecommendation,
|
||||
summaryDetailText,
|
||||
dialogHintText,
|
||||
dialogDescriptionText,
|
||||
dialogSectionText,
|
||||
dialogBg,
|
||||
dialogBorder,
|
||||
controlBg,
|
||||
controlBorder,
|
||||
controlText,
|
||||
controlPlaceholder,
|
||||
controlHoverBg,
|
||||
buttonBg,
|
||||
buttonBorder,
|
||||
buttonText,
|
||||
buttonHoverBg,
|
||||
buttonPressedBg,
|
||||
primaryButtonBg,
|
||||
primaryButtonBorder,
|
||||
primaryButtonText,
|
||||
primaryButtonHoverBg,
|
||||
primaryButtonPressedBg,
|
||||
spacingXs,
|
||||
spacingSm,
|
||||
spacingMd,
|
||||
spacingLg,
|
||||
radiusSm,
|
||||
radiusMd,
|
||||
compactControlHeight,
|
||||
dashboardSurfaceComboWidth,
|
||||
dashboardMetricsHeight,
|
||||
dashboardBottomMargin,
|
||||
palettePanelWidth,
|
||||
stageRowHeight,
|
||||
stageHandleWidth,
|
||||
reconstructionBadgeWidth,
|
||||
paletteItemHeight,
|
||||
dialogWidth,
|
||||
dialogLabelWidth,
|
||||
dialogResetButtonWidth,
|
||||
dialogOkButtonWidth,
|
||||
dialogContentSpacing,
|
||||
dialogRowSpacing,
|
||||
fontXs,
|
||||
fontSm,
|
||||
fontMd,
|
||||
fontLg,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
import PipelineCanvas from "@/components/pipeline/PipelineCanvas.vue";
|
||||
import StageSettingsDialog from "@/components/pipeline/StageSettingsDialog.vue";
|
||||
|
||||
const store = usePipelineStore();
|
||||
const selectedPreset = ref("");
|
||||
const snapshotName = ref("");
|
||||
const presetTitle = ref("");
|
||||
const settings = ref(null);
|
||||
|
||||
async function onApplyPreset() {
|
||||
if (!selectedPreset.value) return;
|
||||
await store.applyPreset(selectedPreset.value);
|
||||
}
|
||||
|
||||
async function onRun() {
|
||||
const useBinary = (store.currentFile?.size || 0) > 8 * 1024 * 1024;
|
||||
await store.runPipeline({ geometryFormat: useBinary ? "binary" : "json" });
|
||||
if (store.geometryUrl) {
|
||||
const workId = store.geometryUrl.split("/").pop();
|
||||
await store.loadGeometryFromUrl(workId);
|
||||
}
|
||||
}
|
||||
|
||||
async function onGenerateDemo() {
|
||||
await store.runPipeline({ useDemo: true, geometryFormat: "json" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel controls">
|
||||
<div class="toolbar">
|
||||
<select v-model="store.demoSurfaceType">
|
||||
<option v-for="type in store.demoSurfaceTypes" :key="type" :value="type">{{ type }}</option>
|
||||
</select>
|
||||
<button type="button" @click="onGenerateDemo" :disabled="store.busy">Сгенерировать</button>
|
||||
<label class="file-label">
|
||||
Загрузить
|
||||
<input type="file" accept=".ply,.txt,.csv,.xyz,.bin,.obj" @change="store.setCurrentFile($event.target.files?.[0] || null)" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<PipelineCanvas @open-settings="settings?.open($event)" />
|
||||
|
||||
<div class="toolbar">
|
||||
<label><input type="checkbox" v-model="store.surfaceVisible" /> Показать mesh</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ primary: store.pipelineNeedsApply, applied: !store.pipelineNeedsApply }"
|
||||
:disabled="store.busy"
|
||||
@click="onRun"
|
||||
>{{ store.applyButtonLabel }}</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar presets">
|
||||
<select v-model="selectedPreset">
|
||||
<option value="">Пресет...</option>
|
||||
<option v-for="preset in store.presetItems" :key="preset.idValue || preset.id" :value="preset.idValue || preset.id">
|
||||
{{ preset.title }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" @click="onApplyPreset">Применить пресет</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar presets">
|
||||
<input v-model="presetTitle" placeholder="Имя пресета" />
|
||||
<button type="button" @click="store.saveCurrentPreset(presetTitle)" :disabled="!presetTitle">Сохранить пресет</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar presets">
|
||||
<input v-model="snapshotName" placeholder="Имя конфигурации" />
|
||||
<button type="button" @click="store.saveSnapshot(snapshotName)">Сохр. конф.</button>
|
||||
<button
|
||||
v-for="snap in store.snapshots"
|
||||
:key="snap.name"
|
||||
type="button"
|
||||
@click="store.loadSnapshot(snap.name)"
|
||||
>{{ snap.name }}</button>
|
||||
</div>
|
||||
|
||||
<p class="status">{{ store.statusText }}</p>
|
||||
<StageSettingsDialog ref="settings" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.controls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.file-label { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; }
|
||||
.status { color: var(--muted-text); font-size: 13px; white-space: pre-wrap; margin: 0; }
|
||||
.presets input { flex: 1; min-width: 120px; }
|
||||
button.applied {
|
||||
opacity: 0.85;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
|
||||
const store = usePipelineStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wizard panel">
|
||||
<h3>Quick Wizard</h3>
|
||||
<label>Data profile</label>
|
||||
<select v-model="store.wizardProfile">
|
||||
<option value="general">general</option>
|
||||
<option value="urban_scan">urban_scan</option>
|
||||
<option value="indoor_object">indoor_object</option>
|
||||
</select>
|
||||
<label>Optimization goal</label>
|
||||
<select v-model="store.wizardGoal">
|
||||
<option value="speed">speed</option>
|
||||
<option value="balanced">balanced</option>
|
||||
<option value="quality">quality</option>
|
||||
</select>
|
||||
<p class="hint">Wizard builds a start chain and explains trade-offs.</p>
|
||||
<button type="button" @click="store.applyWizard()">Generate suggested chain</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wizard { display: grid; gap: 8px; }
|
||||
.wizard h3 { margin: 0; font-size: 14px; }
|
||||
.hint { color: var(--hint-text); font-size: 12px; margin: 0; }
|
||||
label { font-size: 12px; color: var(--label-text); }
|
||||
</style>
|
||||
@@ -0,0 +1,461 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useGeneratorStore } from "@/stores/generator";
|
||||
import LayerSettingsForm from "@/components/generator/LayerSettingsForm.vue";
|
||||
|
||||
const store = useGeneratorStore();
|
||||
const {
|
||||
busy,
|
||||
statusText,
|
||||
layers,
|
||||
selectedLayerId,
|
||||
exportFormat,
|
||||
objectTypes,
|
||||
surfaceTypes,
|
||||
historyEntries,
|
||||
historyIndex,
|
||||
canUndo,
|
||||
canRedo,
|
||||
} = storeToRefs(store);
|
||||
|
||||
const selected = computed(() => store.selectedLayer);
|
||||
const selectedSchema = computed(() => {
|
||||
if (!selected.value) return null;
|
||||
return store.schemaFor(selected.value.kind, selected.value.type);
|
||||
});
|
||||
|
||||
const pendingObjectType = ref("pipe");
|
||||
const pendingSurfaceType = ref("ocean_floor");
|
||||
|
||||
/** Primary workflow open by default; secondary panels collapsed. */
|
||||
const open = reactive({
|
||||
add: true,
|
||||
layers: true,
|
||||
settings: false,
|
||||
history: false,
|
||||
actions: true,
|
||||
});
|
||||
|
||||
function toggle(key) {
|
||||
open[key] = !open[key];
|
||||
}
|
||||
|
||||
function onObjectTypeChange(event) {
|
||||
pendingObjectType.value = event.target.value;
|
||||
}
|
||||
|
||||
function onSurfaceTypeChange(event) {
|
||||
pendingSurfaceType.value = event.target.value;
|
||||
}
|
||||
|
||||
function addObject() {
|
||||
const type = pendingObjectType.value || objectTypes.value[0]?.type;
|
||||
if (type) store.addLayer("object", type);
|
||||
}
|
||||
|
||||
function addSurface() {
|
||||
const type = pendingSurfaceType.value || surfaceTypes.value[0]?.type;
|
||||
if (type) store.addLayer("surface", type);
|
||||
}
|
||||
|
||||
function onImportCloud(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) store.importCloudFile(file);
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
watch(
|
||||
objectTypes,
|
||||
(items) => {
|
||||
if (items.length && !items.find((item) => item.type === pendingObjectType.value)) {
|
||||
pendingObjectType.value = items[0].type;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
surfaceTypes,
|
||||
(items) => {
|
||||
if (items.length && !items.find((item) => item.type === pendingSurfaceType.value)) {
|
||||
pendingSurfaceType.value = items[0].type;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel generator-sidebar">
|
||||
<h2>Генератор сцены</h2>
|
||||
<p class="status">{{ statusText }}</p>
|
||||
|
||||
<div class="block accordion" :class="{ open: open.add }">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-head"
|
||||
:aria-expanded="open.add"
|
||||
@click="toggle('add')"
|
||||
>
|
||||
<span>Добавить слой</span>
|
||||
<span class="chevron" aria-hidden="true">{{ open.add ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<div v-show="open.add" class="accordion-body">
|
||||
<div class="row">
|
||||
<select
|
||||
:value="pendingObjectType"
|
||||
:disabled="busy || !objectTypes.length"
|
||||
@change="onObjectTypeChange"
|
||||
>
|
||||
<option v-for="item in objectTypes" :key="item.type" :value="item.type">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" :disabled="busy || !objectTypes.length" @click="addObject">
|
||||
Объект
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<select
|
||||
:value="pendingSurfaceType"
|
||||
:disabled="busy || !surfaceTypes.length"
|
||||
@change="onSurfaceTypeChange"
|
||||
>
|
||||
<option v-for="item in surfaceTypes" :key="item.type" :value="item.type">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" :disabled="busy || !surfaceTypes.length" @click="addSurface">
|
||||
Поверхность
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block accordion" :class="{ open: open.layers }">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-head"
|
||||
:aria-expanded="open.layers"
|
||||
@click="toggle('layers')"
|
||||
>
|
||||
<span>Слои</span>
|
||||
<span class="chevron" aria-hidden="true">{{ open.layers ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<div v-show="open.layers" class="accordion-body">
|
||||
<ul v-if="layers.length" class="layer-list">
|
||||
<li
|
||||
v-for="layer in layers"
|
||||
:key="layer.id"
|
||||
:class="{ selected: layer.id === selectedLayerId }"
|
||||
@click="store.selectLayer(layer.id)"
|
||||
>
|
||||
<span class="swatch" :style="{ background: layer.color }" />
|
||||
<div class="layer-meta">
|
||||
<strong>{{ layer.name }}</strong>
|
||||
<small>{{ layer.points?.length || 0 }} т. · {{ layer.kind }}</small>
|
||||
</div>
|
||||
<label class="vis" @click.stop>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="layer.visible !== false"
|
||||
@change="store.setLayerVisible(layer.id, $event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" class="ghost" :disabled="busy" @click.stop="store.removeLayer(layer.id)">
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="muted">Пока нет слоёв</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block accordion" :class="{ open: open.settings }">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-head"
|
||||
:aria-expanded="open.settings"
|
||||
@click="toggle('settings')"
|
||||
>
|
||||
<span>Настройки генерации</span>
|
||||
<span class="chevron" aria-hidden="true">{{ open.settings ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<div v-show="open.settings" class="accordion-body">
|
||||
<LayerSettingsForm
|
||||
:schema="selectedSchema"
|
||||
:params="selected?.params || {}"
|
||||
:disabled="busy || !selected"
|
||||
@update="store.updateSelectedParams($event)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="primary full"
|
||||
:disabled="busy || !selected"
|
||||
@click="store.regenerateSelected()"
|
||||
>
|
||||
Перегенерировать слой
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block accordion" :class="{ open: open.history }">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-head"
|
||||
:aria-expanded="open.history"
|
||||
@click="toggle('history')"
|
||||
>
|
||||
<span>История</span>
|
||||
<span class="chevron" aria-hidden="true">{{ open.history ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<div v-show="open.history" class="accordion-body">
|
||||
<div class="history-toolbar">
|
||||
<button type="button" :disabled="busy || !canUndo" title="Ctrl+Z" @click="store.undo()">
|
||||
Назад
|
||||
</button>
|
||||
<button type="button" :disabled="busy || !canRedo" title="Ctrl+Shift+Z" @click="store.redo()">
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
<ul class="history-list" aria-label="История действий">
|
||||
<li
|
||||
v-for="(entry, index) in historyEntries"
|
||||
:key="entry.id"
|
||||
:class="{
|
||||
current: index === historyIndex,
|
||||
future: index > historyIndex,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="history-item"
|
||||
:disabled="busy"
|
||||
@click="store.jumpToHistory(index)"
|
||||
>
|
||||
{{ entry.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="muted">Ctrl+Z · Ctrl+Shift+Z / Ctrl+Y</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block accordion" :class="{ open: open.actions }">
|
||||
<button
|
||||
type="button"
|
||||
class="accordion-head"
|
||||
:aria-expanded="open.actions"
|
||||
@click="toggle('actions')"
|
||||
>
|
||||
<span>Действия</span>
|
||||
<span class="chevron" aria-hidden="true">{{ open.actions ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<div v-show="open.actions" class="accordion-body">
|
||||
<label class="format">
|
||||
Формат
|
||||
<select :value="exportFormat" :disabled="busy" @change="store.setExportFormat($event.target.value)">
|
||||
<option value="xyz">XYZ</option>
|
||||
<option value="ply">PLY</option>
|
||||
<option value="obj">OBJ</option>
|
||||
<option value="npy">NPY (PointNet)</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-if="exportFormat === 'npy'" class="muted">
|
||||
NPY float64 (N×7): x y z r g b class · RGB=0 · class 1=труба, 0=остальное · без нормализации
|
||||
</p>
|
||||
<label class="format import-obj">
|
||||
Загрузить облако
|
||||
<input
|
||||
type="file"
|
||||
accept=".obj,.ply,.xyz,.txt,.csv,model/obj,text/plain"
|
||||
:disabled="busy"
|
||||
@change="onImportCloud($event)"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" :disabled="busy || !layers.length" @click="store.resolveIntersections()">
|
||||
Удалить пересечения
|
||||
</button>
|
||||
<button type="button" :disabled="busy || !selected" @click="store.saveSelectedLayer()">
|
||||
Сохранить слой
|
||||
</button>
|
||||
<button type="button" :disabled="busy || !layers.length" @click="store.saveAllLayersSeparately()">
|
||||
Сохранить каждый слой
|
||||
</button>
|
||||
<button type="button" class="primary" :disabled="busy || !layers.length" @click="store.saveScene()">
|
||||
Сохранить сцену
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.generator-sidebar {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
h2 { margin: 0; font-size: 18px; }
|
||||
.status {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--summary-secondary);
|
||||
}
|
||||
.block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--card-border);
|
||||
}
|
||||
.accordion {
|
||||
gap: 0;
|
||||
}
|
||||
.accordion-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--label-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.accordion-head:hover {
|
||||
color: var(--control-text);
|
||||
}
|
||||
.chevron {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text);
|
||||
line-height: 1;
|
||||
}
|
||||
.accordion-body {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
.layer-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
.layer-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 12px 1fr auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--chain-idle-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--chain-enabled-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.layer-list li.selected {
|
||||
border-color: var(--chain-selected-border);
|
||||
}
|
||||
.swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.layer-meta {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.layer-meta strong {
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.layer-meta small {
|
||||
color: var(--muted-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
.vis input { margin: 0; }
|
||||
.ghost {
|
||||
padding: 2px 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
.full { width: 100%; }
|
||||
.format {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.format select { width: auto; min-width: 90px; }
|
||||
.history-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--chain-enabled-bg);
|
||||
}
|
||||
.history-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--control-text);
|
||||
}
|
||||
.history-list li.current .history-item {
|
||||
background: var(--control-bg);
|
||||
border-left: 3px solid var(--chain-selected-border);
|
||||
font-weight: 600;
|
||||
}
|
||||
.history-list li.future .history-item {
|
||||
color: var(--muted-text);
|
||||
opacity: 0.65;
|
||||
}
|
||||
.history-item:hover:not(:disabled) {
|
||||
background: var(--button-bg);
|
||||
}
|
||||
.import-obj {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
.import-obj input[type="file"] {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.accordion-body > button { width: 100%; }
|
||||
.row button { width: auto; }
|
||||
.layer-list button { width: auto; }
|
||||
.history-toolbar button { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useGeneratorStore } from "@/stores/generator";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { useSceneCloudViewer } from "@/composables/useSceneCloudViewer";
|
||||
import TransformToolbar from "@/components/generator/TransformToolbar.vue";
|
||||
|
||||
const store = useGeneratorStore();
|
||||
const {
|
||||
layers,
|
||||
selectedLayerId,
|
||||
interactionMode,
|
||||
translateStep,
|
||||
rotateStepDeg,
|
||||
viewerRevision,
|
||||
busy,
|
||||
} = storeToRefs(store);
|
||||
const { theme } = useTheme();
|
||||
const containerRef = ref(null);
|
||||
|
||||
const viewer = useSceneCloudViewer(containerRef, {
|
||||
getLayers: () => store.layers,
|
||||
getSelectedId: () => store.selectedLayerId,
|
||||
getMode: () => store.interactionMode,
|
||||
onTransform: (id, transform) => {
|
||||
store.setLayerTransform(id, transform, { refresh: false });
|
||||
},
|
||||
onTransformGestureEnd: (label) => {
|
||||
store.endTransformGesture(label);
|
||||
},
|
||||
});
|
||||
|
||||
const hasSelection = computed(() => !!store.selectedLayerId);
|
||||
|
||||
function sync(fit = false) {
|
||||
viewer.syncLayers(store.layers, { fit });
|
||||
viewer.setMode(store.interactionMode);
|
||||
}
|
||||
|
||||
watch(viewerRevision, () => sync(false));
|
||||
watch(selectedLayerId, () => {
|
||||
viewer.setMode(store.interactionMode);
|
||||
sync(false);
|
||||
});
|
||||
watch(interactionMode, (mode) => viewer.setMode(mode));
|
||||
watch(theme, (value) => {
|
||||
viewer.setBackground(value === "light" ? 0xe2e8f0 : 0x0b1118);
|
||||
});
|
||||
|
||||
function onKeydown(event) {
|
||||
if (store.busy) return;
|
||||
const tag = event.target?.tagName;
|
||||
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return;
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && (event.key === "z" || event.key === "Z")) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) store.redo();
|
||||
else store.undo();
|
||||
return;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && (event.key === "y" || event.key === "Y")) {
|
||||
event.preventDefault();
|
||||
store.redo();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!store.selectedLayer) return;
|
||||
|
||||
let handled = true;
|
||||
if (store.interactionMode === "rotate") {
|
||||
const rad = (store.rotateStepDeg * Math.PI) / 180;
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
store.nudgeSelectedRotation(0, 0, rad);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
store.nudgeSelectedRotation(0, 0, -rad);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
store.nudgeSelectedRotation(rad, 0, 0);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
store.nudgeSelectedRotation(-rad, 0, 0);
|
||||
break;
|
||||
case "q":
|
||||
case "Q":
|
||||
store.nudgeSelectedRotation(0, rad, 0);
|
||||
break;
|
||||
case "e":
|
||||
case "E":
|
||||
store.nudgeSelectedRotation(0, -rad, 0);
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
}
|
||||
} else {
|
||||
const step = store.translateStep;
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
store.nudgeSelected(-step, 0, 0);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
store.nudgeSelected(step, 0, 0);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
store.nudgeSelected(0, step, 0);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
store.nudgeSelected(0, -step, 0);
|
||||
break;
|
||||
case "PageUp":
|
||||
store.nudgeSelected(0, 0, step);
|
||||
break;
|
||||
case "PageDown":
|
||||
store.nudgeSelected(0, 0, -step);
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
}
|
||||
}
|
||||
if (handled) event.preventDefault();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
viewer.setBackground(theme.value === "light" ? 0xe2e8f0 : 0x0b1118);
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
sync(true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="viewer-panel panel">
|
||||
<TransformToolbar
|
||||
:mode="interactionMode"
|
||||
:step="translateStep"
|
||||
:rotate-step-deg="rotateStepDeg"
|
||||
:has-selection="hasSelection"
|
||||
@update:mode="store.setInteractionMode($event)"
|
||||
@update:step="store.setTranslateStep($event)"
|
||||
@update:rotate-step-deg="store.setRotateStepDeg($event)"
|
||||
/>
|
||||
<div class="viewport-wrap">
|
||||
<div ref="containerRef" class="viewport" tabindex="0" />
|
||||
<div v-if="busy" class="busy-overlay">Генерация…</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<button type="button" @click="viewer.resetCamera()">Сброс камеры</button>
|
||||
<span class="muted">Слоёв: {{ layers.length }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.viewer-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
}
|
||||
.viewport-wrap {
|
||||
position: relative;
|
||||
min-height: 360px;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--viewer-bg);
|
||||
}
|
||||
.viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 360px;
|
||||
outline: none;
|
||||
}
|
||||
.busy-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--dialog-backdrop) 70%, transparent);
|
||||
color: var(--control-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.muted {
|
||||
font-size: 12px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
.footer button {
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
schema: { type: Object, default: null },
|
||||
params: { type: Object, default: () => ({}) },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update"]);
|
||||
|
||||
function onChange(key, value, fieldType) {
|
||||
let next = value;
|
||||
if (fieldType === "number") {
|
||||
next = value === "" ? 0 : Number(value);
|
||||
}
|
||||
emit("update", { [key]: next });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="schema" class="settings-form">
|
||||
<div v-for="field in schema.params" :key="field.key" class="field">
|
||||
<label :for="`gen-${field.key}`">{{ field.label }}</label>
|
||||
<select
|
||||
v-if="field.type === 'select'"
|
||||
:id="`gen-${field.key}`"
|
||||
:value="params[field.key]"
|
||||
:disabled="disabled"
|
||||
@change="onChange(field.key, $event.target.value, 'select')"
|
||||
>
|
||||
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
:id="`gen-${field.key}`"
|
||||
type="number"
|
||||
:value="params[field.key]"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:step="field.step"
|
||||
:disabled="disabled"
|
||||
@change="onChange(field.key, $event.target.value, 'number')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted">Выберите слой</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
color: var(--label-text);
|
||||
}
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
mode: { type: String, default: "orbit" },
|
||||
step: { type: Number, default: 0.05 },
|
||||
rotateStepDeg: { type: Number, default: 5 },
|
||||
hasSelection: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:mode", "update:step", "update:rotateStepDeg"]);
|
||||
|
||||
const stepLabel = computed(() => (props.mode === "rotate" ? "Шаг °" : "Шаг"));
|
||||
const stepValue = computed(() => (props.mode === "rotate" ? props.rotateStepDeg : props.step));
|
||||
const hint = computed(() => {
|
||||
if (props.mode === "rotate") {
|
||||
return "Стрелки / Q·E — вращение. Мышью — gizmo вращения.";
|
||||
}
|
||||
if (props.mode === "translate") {
|
||||
return "Стрелки — XY, PageUp/PageDown — Z. Мышью — gizmo перемещения.";
|
||||
}
|
||||
return "Орбита камеры. Выберите слой и режим Перемещение / Вращение.";
|
||||
});
|
||||
|
||||
function onStepChange(event) {
|
||||
const value = Number(event.target.value);
|
||||
if (props.mode === "rotate") {
|
||||
emit("update:rotateStepDeg", value || 5);
|
||||
} else {
|
||||
emit("update:step", value || 0.05);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div class="modes">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: mode === 'orbit' }"
|
||||
@click="emit('update:mode', 'orbit')"
|
||||
>
|
||||
Орбита
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: mode === 'translate' }"
|
||||
:disabled="!hasSelection"
|
||||
@click="emit('update:mode', 'translate')"
|
||||
>
|
||||
Перемещение
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: mode === 'rotate' }"
|
||||
:disabled="!hasSelection"
|
||||
@click="emit('update:mode', 'rotate')"
|
||||
>
|
||||
Вращение
|
||||
</button>
|
||||
</div>
|
||||
<label class="step">
|
||||
{{ stepLabel }}
|
||||
<input
|
||||
type="number"
|
||||
:min="mode === 'rotate' ? 0.5 : 0.001"
|
||||
:max="mode === 'rotate' ? 90 : 2"
|
||||
:step="mode === 'rotate' ? 0.5 : 0.01"
|
||||
:value="stepValue"
|
||||
@change="onStepChange"
|
||||
/>
|
||||
</label>
|
||||
<p class="hint">{{ hint }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.modes { display: flex; gap: 6px; }
|
||||
.modes button.active {
|
||||
border-color: var(--chain-selected-border);
|
||||
font-weight: 600;
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--label-text);
|
||||
}
|
||||
.step input {
|
||||
width: 72px;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--hint-text);
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
|
||||
const store = usePipelineStore();
|
||||
|
||||
function localizedHealth(value) {
|
||||
if (value === "OK") return "ОК";
|
||||
if (value === "Warning") return "Предупреждение";
|
||||
if (value === "Risk") return "Риск";
|
||||
return value || "-";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel metrics-strip">
|
||||
<h3>Сводка</h3>
|
||||
<div class="row">
|
||||
<strong>Состояние: {{ localizedHealth(store.chainHealth) }}</strong>
|
||||
<span>Треугольники: {{ store.metrics.triangles }}</span>
|
||||
<span>Реконструкция, мс: {{ store.metrics.reconstructMs }}</span>
|
||||
</div>
|
||||
<p class="recommendation">{{ store.recommendation }}</p>
|
||||
<ul v-if="store.warningsList.length" class="warnings">
|
||||
<li v-for="(warning, index) in store.warningsList" :key="index">{{ warning }}</li>
|
||||
</ul>
|
||||
<table v-if="store.stageMetrics.length" class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Этап</th>
|
||||
<th>Вход</th>
|
||||
<th>Выход</th>
|
||||
<th>Удалено</th>
|
||||
<th>мс</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in store.stageMetrics" :key="row.stageId">
|
||||
<td>{{ row.stageId }}</td>
|
||||
<td>{{ row.inputPoints }}</td>
|
||||
<td>{{ row.outputPoints }}</td>
|
||||
<td>{{ row.removedPoints }}</td>
|
||||
<td>{{ row.elapsedMs }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="summary-grid">
|
||||
<div>Вход: {{ store.metrics.inputPoints }}</div>
|
||||
<div>После preprocess: {{ store.metrics.afterPreprocess }}</div>
|
||||
<div>Кластеры: {{ store.metrics.clusters }}</div>
|
||||
<div>Удалено: {{ store.metrics.removedPoints }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metrics-strip h3 { margin: 0 0 8px; }
|
||||
.row { display: flex; gap: 12px; flex-wrap: wrap; font-size: 13px; }
|
||||
.recommendation { color: var(--summary-recommendation); font-size: 13px; }
|
||||
.warnings { margin: 8px 0; padding-left: 18px; color: var(--warning-text); font-size: 13px; }
|
||||
.metrics-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
|
||||
.metrics-table th, .metrics-table td { border-bottom: 1px solid var(--card-border); padding: 4px; text-align: left; }
|
||||
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 8px; font-size: 13px; color: var(--summary-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import StageChainView from "./StageChainView.vue";
|
||||
import StagePalette from "./StagePalette.vue";
|
||||
|
||||
const emit = defineEmits(["openSettings"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="canvas panel">
|
||||
<div class="canvas-grid">
|
||||
<StageChainView @open-settings="emit('openSettings', $event)" />
|
||||
<StagePalette />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 260px);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
max-height: min(420px, 48vh);
|
||||
}
|
||||
|
||||
.canvas-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.canvas-grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import Sortable from "sortablejs";
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
|
||||
const store = usePipelineStore();
|
||||
const listRef = ref(null);
|
||||
const emit = defineEmits(["openSettings"]);
|
||||
|
||||
function categoryColor(category) {
|
||||
const map = {
|
||||
"Обрезка": "#88d1ff",
|
||||
"NaN (предочистка)": "#8fe8ff",
|
||||
"Условия/Индексы": "#9fd7ff",
|
||||
"Шум": "#a4f4b9",
|
||||
"Морфология": "#9cf5d1",
|
||||
"Прореживание": "#ffcb8a",
|
||||
"Сглаживание": "#ffd892",
|
||||
"Нормали": "#d2b7ff",
|
||||
"Реконструкция": "#ff9dc4",
|
||||
};
|
||||
return map[category] || "#9fb4da";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!listRef.value) return;
|
||||
Sortable.create(listRef.value, {
|
||||
animation: 150,
|
||||
handle: ".drag-handle",
|
||||
onEnd(evt) {
|
||||
if (evt.oldIndex == null || evt.newIndex == null) return;
|
||||
store.moveStage(evt.oldIndex, evt.newIndex);
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="listRef" class="chain-list">
|
||||
<div
|
||||
v-for="(card, index) in store.stageCards"
|
||||
:key="`${card.id}-${index}`"
|
||||
class="chain-row"
|
||||
:class="{ selected: store.selectedStageIndex === index, disabled: !card.enabled }"
|
||||
@click="store.setSelectedStageIndex(index)"
|
||||
@dblclick="emit('openSettings', card)"
|
||||
>
|
||||
<span class="accent" :style="{ background: categoryColor(card.category) }" />
|
||||
<span class="drag-handle" :class="{ hidden: card.family === 'reconstruction' }">↕</span>
|
||||
<div class="meta">
|
||||
<strong>{{ card.title }}</strong>
|
||||
<small>{{ card.category }} | {{ card.id }}</small>
|
||||
</div>
|
||||
<button
|
||||
v-if="card.family !== 'reconstruction'"
|
||||
type="button"
|
||||
class="mini"
|
||||
@click.stop="store.removeStage(index)"
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chain-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: min(420px, 48vh);
|
||||
overflow: auto;
|
||||
}
|
||||
.chain-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4px 20px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--chain-idle-border);
|
||||
background: var(--chain-enabled-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chain-row.selected { border-color: var(--chain-selected-border); }
|
||||
.chain-row.disabled { background: var(--chain-disabled-bg); opacity: 0.7; }
|
||||
.accent { height: 100%; border-radius: 2px; }
|
||||
.drag-handle { text-align: center; color: var(--chain-handle-text); cursor: grab; }
|
||||
.drag-handle.hidden { visibility: hidden; }
|
||||
.meta { display: grid; gap: 2px; min-width: 0; }
|
||||
.meta strong, .meta small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta small { color: var(--chain-handle-text); font-size: 11px; }
|
||||
.mini { width: 28px; height: 28px; padding: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
|
||||
const store = usePipelineStore();
|
||||
const { theme } = useTheme();
|
||||
|
||||
function addFilter(item) {
|
||||
store.addStage(item.idValue);
|
||||
}
|
||||
|
||||
function phaseHeaderStyle(group) {
|
||||
if (theme.value === "light") {
|
||||
return {
|
||||
color: "#1e293b",
|
||||
borderColor: group.phaseFilterBorder,
|
||||
background: `color-mix(in srgb, ${group.phaseAccent} 16%, #ffffff)`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
color: group.phaseAccent,
|
||||
borderColor: group.phaseFilterBorder,
|
||||
background: group.phaseFilterBg,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="palette">
|
||||
<section v-for="group in store.paletteGroups" :key="group.phaseId" class="phase">
|
||||
<header :style="phaseHeaderStyle(group)">
|
||||
{{ group.phaseTitle }}
|
||||
</header>
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.idValue"
|
||||
type="button"
|
||||
class="palette-item"
|
||||
@click="addFilter(item)"
|
||||
>
|
||||
<span>{{ item.title }}</span>
|
||||
<small>{{ item.hint }}</small>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.palette {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: min(420px, 48vh);
|
||||
overflow: auto;
|
||||
}
|
||||
.phase header {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--palette-filter-border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.palette-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-bottom: 4px;
|
||||
background: var(--palette-filter-bg);
|
||||
border-color: var(--palette-filter-border);
|
||||
color: var(--control-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.palette-item span,
|
||||
.palette-item small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.palette-item small { color: var(--palette-hint-text); font-size: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
import { parseDefaults, serializeParams, paramDescription } from "@/catalog/pipelineUiCatalog";
|
||||
|
||||
const store = usePipelineStore();
|
||||
const visible = ref(false);
|
||||
const paramItems = ref([]);
|
||||
const editingCard = ref(null);
|
||||
|
||||
function open(card) {
|
||||
editingCard.value = card;
|
||||
paramItems.value = parseDefaults(card.defaults || "");
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function save() {
|
||||
const index = store.stageCards.findIndex((c) => c.id === editingCard.value?.id);
|
||||
if (index >= 0) {
|
||||
store.setStageDefaults(index, serializeParams(paramItems.value));
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
async function resetDefaults() {
|
||||
if (!editingCard.value) return;
|
||||
const defaults = await store.defaultsForStage(editingCard.value.id);
|
||||
paramItems.value = parseDefaults(defaults);
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-backdrop" @click.self="close">
|
||||
<div class="dialog panel">
|
||||
<h3>{{ editingCard?.title }}</h3>
|
||||
<p class="hint">{{ editingCard?.hint }}</p>
|
||||
<div v-for="(param, index) in paramItems" :key="param.key" class="param-row">
|
||||
<label>{{ param.key }}</label>
|
||||
<select v-if="param.kind === 'bool'" v-model="param.value">
|
||||
<option value="false">false</option>
|
||||
<option value="true">true</option>
|
||||
</select>
|
||||
<input v-else v-model="param.value" />
|
||||
<small>{{ paramDescription(param.key) }}</small>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" @click="resetDefaults">Установить по умолчанию</button>
|
||||
<button type="button" class="primary" @click="save">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-backdrop { position: fixed; inset: 0; background: var(--dialog-backdrop); display: grid; place-items: center; z-index: 20; }
|
||||
.dialog { width: min(420px, 92vw); max-height: 80vh; overflow: auto; }
|
||||
.hint { color: var(--dialog-hint-text); font-size: 13px; }
|
||||
.param-row { display: grid; gap: 4px; margin-bottom: 10px; }
|
||||
.param-row label { font-size: 12px; color: var(--dialog-label-text); }
|
||||
.param-row small { color: var(--dialog-hint-text); font-size: 11px; }
|
||||
.actions { display: flex; justify-content: space-between; gap: 8px; margin-top: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(["download", "resetCamera"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modebar" role="toolbar" aria-label="Управление 3D-просмотром">
|
||||
<button
|
||||
type="button"
|
||||
class="modebar-btn"
|
||||
title="Сохранить снимок"
|
||||
aria-label="Сохранить снимок"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
class="modebar-btn"
|
||||
title="Сбросить камеру"
|
||||
aria-label="Сбросить камеру"
|
||||
@click="emit('resetCamera')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 10.5 10.5 4 17 10.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M10.5 4v16" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modebar {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 3;
|
||||
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);
|
||||
}
|
||||
|
||||
.modebar-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--modebar-icon, #636363);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modebar-btn:hover {
|
||||
background: color-mix(in srgb, var(--control-text) 8%, transparent);
|
||||
}
|
||||
|
||||
.modebar-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
import { usePointCloudViewer } from "@/composables/usePointCloudViewer";
|
||||
import ViewerModebar from "./ViewerModebar.vue";
|
||||
|
||||
const VIEWPORT_HEIGHT_KEY = "dottosurface-viewport-height";
|
||||
const MIN_VIEWPORT_HEIGHT = 240;
|
||||
const MAX_VIEWPORT_HEIGHT = Math.min(window.innerHeight - 80, 1200);
|
||||
|
||||
const store = usePipelineStore();
|
||||
const { theme } = useTheme();
|
||||
const viewportRef = ref(null);
|
||||
const isFullscreen = ref(false);
|
||||
const viewportHeight = ref(loadViewportHeight());
|
||||
const {
|
||||
renderGeometry,
|
||||
setSurfaceVisible,
|
||||
resize,
|
||||
setBackground,
|
||||
resetCamera,
|
||||
downloadSnapshot,
|
||||
} = usePointCloudViewer(viewportRef);
|
||||
|
||||
function loadViewportHeight() {
|
||||
const saved = Number(localStorage.getItem(VIEWPORT_HEIGHT_KEY));
|
||||
if (Number.isFinite(saved) && saved >= MIN_VIEWPORT_HEIGHT) {
|
||||
return Math.min(saved, MAX_VIEWPORT_HEIGHT);
|
||||
}
|
||||
return 520;
|
||||
}
|
||||
|
||||
function saveViewportHeight() {
|
||||
localStorage.setItem(VIEWPORT_HEIGHT_KEY, String(viewportHeight.value));
|
||||
}
|
||||
|
||||
function viewerBackground() {
|
||||
return theme.value === "light" ? 0xe2e8f0 : 0x0b1118;
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
isFullscreen.value = document.fullscreenElement === viewportRef.value;
|
||||
resize();
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
if (!viewportRef.value) return;
|
||||
if (document.fullscreenElement === viewportRef.value) {
|
||||
await document.exitFullscreen();
|
||||
} else {
|
||||
await viewportRef.value.requestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function startResize(event) {
|
||||
if (isFullscreen.value) return;
|
||||
event.preventDefault();
|
||||
|
||||
const startY = event.clientY;
|
||||
const startHeight = viewportHeight.value;
|
||||
|
||||
function onMove(moveEvent) {
|
||||
const nextHeight = startHeight + (moveEvent.clientY - startY);
|
||||
viewportHeight.value = Math.min(
|
||||
Math.max(nextHeight, MIN_VIEWPORT_HEIGHT),
|
||||
Math.min(window.innerHeight - 80, 1200),
|
||||
);
|
||||
resize();
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
saveViewportHeight();
|
||||
}
|
||||
|
||||
document.body.style.cursor = "ns-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [store.viewerPoints, store.viewerTriangles],
|
||||
() => {
|
||||
renderGeometry(store.viewerPoints, store.viewerTriangles, store.surfaceVisible);
|
||||
resize();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => store.surfaceVisible,
|
||||
(visible) => {
|
||||
setSurfaceVisible(visible);
|
||||
},
|
||||
);
|
||||
|
||||
watch(theme, () => {
|
||||
setBackground(viewerBackground());
|
||||
}, { immediate: true });
|
||||
|
||||
watch(viewportHeight, () => {
|
||||
resize();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
resize();
|
||||
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel viewer">
|
||||
<h2>3D Viewer</h2>
|
||||
<div
|
||||
class="viewport-shell"
|
||||
:class="{ fullscreen: isFullscreen }"
|
||||
:style="isFullscreen ? undefined : { height: `${viewportHeight}px` }"
|
||||
>
|
||||
<div ref="viewportRef" class="viewport">
|
||||
<ViewerModebar
|
||||
@download="downloadSnapshot"
|
||||
@reset-camera="resetCamera"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="fullscreen-btn"
|
||||
:title="isFullscreen ? 'Свернуть' : 'Развернуть на весь экран'"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<svg v-if="!isFullscreen" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 3H3v5M16 3h5v5M16 21h5v-5M8 21H3v-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9 9H3V3M15 9h6V3M15 15h6v6M9 15H3v6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isFullscreen"
|
||||
class="resize-handle"
|
||||
title="Потяните, чтобы изменить высоту"
|
||||
@mousedown="startResize"
|
||||
/>
|
||||
</div>
|
||||
<p class="hint">Drag to rotate, wheel to zoom.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.viewer { display: flex; flex-direction: column; min-width: 0; }
|
||||
.viewer h2 { margin: 0 0 8px; font-size: 18px; }
|
||||
|
||||
.viewport-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--viewer-bg);
|
||||
}
|
||||
|
||||
.viewport:fullscreen {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.fullscreen-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--modebar-icon, #636363);
|
||||
opacity: 0.95;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.fullscreen-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.fullscreen-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
flex: 0 0 10px;
|
||||
margin-top: 4px;
|
||||
cursor: ns-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.resize-handle::after {
|
||||
content: "";
|
||||
width: 56px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--card-border);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.resize-handle:hover::after {
|
||||
background: var(--chain-selected-border);
|
||||
}
|
||||
|
||||
.hint { color: var(--hint-text); font-size: 13px; margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
|
||||
export function usePointCloudViewer(containerRef) {
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0b1118);
|
||||
const camera = new THREE.PerspectiveCamera(55, 1, 0.001, 100000);
|
||||
camera.position.set(2.5, 2, 2.5);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||
let controls = null;
|
||||
let pointCloud = null;
|
||||
let meshObject = null;
|
||||
let animationId = 0;
|
||||
let lastPoints = null;
|
||||
let defaultCameraState = null;
|
||||
|
||||
function storeDefaultCameraState() {
|
||||
defaultCameraState = {
|
||||
position: camera.position.clone(),
|
||||
target: controls?.target.clone() || new THREE.Vector3(),
|
||||
near: camera.near,
|
||||
far: camera.far,
|
||||
};
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
const width = el.clientWidth;
|
||||
const height = el.clientHeight;
|
||||
camera.aspect = width / Math.max(height, 1);
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
controls?.update();
|
||||
renderer.render(scene, camera);
|
||||
animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function clearObjects() {
|
||||
if (pointCloud) {
|
||||
scene.remove(pointCloud);
|
||||
pointCloud.geometry.dispose();
|
||||
pointCloud.material.dispose();
|
||||
pointCloud = null;
|
||||
}
|
||||
if (meshObject) {
|
||||
scene.remove(meshObject);
|
||||
meshObject.geometry.dispose();
|
||||
meshObject.material.dispose();
|
||||
meshObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
function boundsRadius(points) {
|
||||
const box = new THREE.Box3();
|
||||
for (const p of points) box.expandByPoint(new THREE.Vector3(p[0], p[1], p[2]));
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
return {
|
||||
box,
|
||||
radius: Math.max(size.x, size.y, size.z) * 0.6 || 1,
|
||||
};
|
||||
}
|
||||
|
||||
function pointSizeForRadius(radius, pointCount) {
|
||||
// Visible both on dense clouds and small demos; scale with scene size.
|
||||
const densityFactor = Math.min(1.4, Math.max(0.55, 9000 / Math.max(pointCount, 1)));
|
||||
return Math.max(radius * 0.012 * densityFactor, 0.002);
|
||||
}
|
||||
|
||||
function fitCamera(points) {
|
||||
if (!points?.length) return;
|
||||
const { box, radius } = boundsRadius(points);
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
controls.target.copy(center);
|
||||
camera.position.copy(center.clone().add(new THREE.Vector3(radius * 1.8, radius * 1.2, radius * 1.8)));
|
||||
camera.near = Math.max(radius / 1000, 0.0001);
|
||||
camera.far = radius * 100;
|
||||
camera.updateProjectionMatrix();
|
||||
controls?.update();
|
||||
storeDefaultCameraState();
|
||||
}
|
||||
|
||||
function setSurfaceVisible(visible) {
|
||||
if (meshObject) {
|
||||
meshObject.visible = !!visible;
|
||||
}
|
||||
}
|
||||
|
||||
function renderGeometry(points, triangleIndices, surfaceVisible = true) {
|
||||
clearObjects();
|
||||
lastPoints = points;
|
||||
if (!points?.length) return;
|
||||
|
||||
const positions = new Float32Array(points.length * 3);
|
||||
for (let i = 0; i < points.length; i += 1) {
|
||||
positions[i * 3] = points[i][0];
|
||||
positions[i * 3 + 1] = points[i][1];
|
||||
positions[i * 3 + 2] = points[i][2];
|
||||
}
|
||||
|
||||
const { radius } = boundsRadius(points);
|
||||
const pointsGeometry = new THREE.BufferGeometry();
|
||||
pointsGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
pointCloud = new THREE.Points(
|
||||
pointsGeometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: 0x7dd3fc,
|
||||
size: pointSizeForRadius(radius, points.length),
|
||||
sizeAttenuation: true,
|
||||
}),
|
||||
);
|
||||
scene.add(pointCloud);
|
||||
|
||||
if (triangleIndices?.length) {
|
||||
const indices = new Uint32Array(triangleIndices.length * 3);
|
||||
for (let i = 0; i < triangleIndices.length; i += 1) {
|
||||
indices[i * 3] = triangleIndices[i][0];
|
||||
indices[i * 3 + 1] = triangleIndices[i][1];
|
||||
indices[i * 3 + 2] = triangleIndices[i][2];
|
||||
}
|
||||
const meshGeometry = new THREE.BufferGeometry();
|
||||
meshGeometry.setAttribute("position", new THREE.BufferAttribute(positions.slice(), 3));
|
||||
meshGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
|
||||
meshGeometry.computeVertexNormals();
|
||||
meshObject = new THREE.Mesh(
|
||||
meshGeometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x60a5fa,
|
||||
transparent: true,
|
||||
opacity: 0.55,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
meshObject.visible = !!surfaceVisible;
|
||||
scene.add(meshObject);
|
||||
}
|
||||
fitCamera(points);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render labeled cloud rows: [x, y, z, class].
|
||||
* highlightClass: null = both classes colored; 0/1 = emphasize that class.
|
||||
*/
|
||||
function renderLabeledCloud(rows, { highlightClass = null, fit = true } = {}) {
|
||||
clearObjects();
|
||||
const points = (rows || []).map((r) => [r[0], r[1], r[2]]);
|
||||
lastPoints = points;
|
||||
if (!points.length) return;
|
||||
|
||||
const positions = new Float32Array(points.length * 3);
|
||||
const colors = new Float32Array(points.length * 3);
|
||||
const hl =
|
||||
highlightClass === null || highlightClass === undefined || highlightClass === ""
|
||||
? null
|
||||
: Number(highlightClass);
|
||||
|
||||
const colorAll = {
|
||||
0: new THREE.Color(0x64748b),
|
||||
1: new THREE.Color(0xf59e0b),
|
||||
};
|
||||
const colorHi = {
|
||||
0: new THREE.Color(0x38bdf8),
|
||||
1: new THREE.Color(0xfbbf24),
|
||||
};
|
||||
const colorDim = new THREE.Color(0x1e293b);
|
||||
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
positions[i * 3] = rows[i][0];
|
||||
positions[i * 3 + 1] = rows[i][1];
|
||||
positions[i * 3 + 2] = rows[i][2];
|
||||
const cls = Math.round(Number(rows[i][3] ?? 0));
|
||||
let c;
|
||||
if (hl === null || Number.isNaN(hl)) {
|
||||
c = colorAll[cls] || colorAll[0];
|
||||
} else if (cls === hl) {
|
||||
c = colorHi[cls] || colorHi[1];
|
||||
} else {
|
||||
c = colorDim;
|
||||
}
|
||||
colors[i * 3] = c.r;
|
||||
colors[i * 3 + 1] = c.g;
|
||||
colors[i * 3 + 2] = c.b;
|
||||
}
|
||||
|
||||
const { radius } = boundsRadius(points);
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
pointCloud = new THREE.Points(
|
||||
geometry,
|
||||
new THREE.PointsMaterial({
|
||||
size: pointSizeForRadius(radius, points.length) * (hl === null ? 1 : 1.15),
|
||||
sizeAttenuation: true,
|
||||
vertexColors: true,
|
||||
}),
|
||||
);
|
||||
scene.add(pointCloud);
|
||||
if (fit) fitCamera(points);
|
||||
}
|
||||
|
||||
function resetCamera() {
|
||||
if (defaultCameraState && controls) {
|
||||
camera.position.copy(defaultCameraState.position);
|
||||
controls.target.copy(defaultCameraState.target);
|
||||
camera.near = defaultCameraState.near;
|
||||
camera.far = defaultCameraState.far;
|
||||
camera.updateProjectionMatrix();
|
||||
controls.update();
|
||||
return;
|
||||
}
|
||||
if (lastPoints?.length) {
|
||||
fitCamera(lastPoints);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadSnapshot() {
|
||||
renderer.render(scene, camera);
|
||||
const dataUrl = renderer.domElement.toDataURL("image/png");
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = `dottosurface-view-${Date.now()}.png`;
|
||||
link.click();
|
||||
}
|
||||
|
||||
function setBackground(color) {
|
||||
scene.background = new THREE.Color(color);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
el.appendChild(renderer.domElement);
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
|
||||
const light = new THREE.DirectionalLight(0xffffff, 0.9);
|
||||
light.position.set(4, 6, 3);
|
||||
scene.add(light);
|
||||
resize();
|
||||
animate();
|
||||
window.addEventListener("resize", resize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animationId);
|
||||
window.removeEventListener("resize", resize);
|
||||
clearObjects();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
return {
|
||||
renderGeometry,
|
||||
renderLabeledCloud,
|
||||
setSurfaceVisible,
|
||||
resize,
|
||||
setBackground,
|
||||
resetCamera,
|
||||
downloadSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
|
||||
|
||||
function parseColor(hex, fallback = 0x7dd3fc) {
|
||||
if (!hex) return fallback;
|
||||
try {
|
||||
return new THREE.Color(hex).getHex();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function isGizmoMode(mode) {
|
||||
return mode === "translate" || mode === "rotate";
|
||||
}
|
||||
|
||||
function rotateXyz(x, y, z, rx, ry, rz) {
|
||||
const cx = Math.cos(rx);
|
||||
const sx = Math.sin(rx);
|
||||
const cy = Math.cos(ry);
|
||||
const sy = Math.sin(ry);
|
||||
const cz = Math.cos(rz);
|
||||
const sz = Math.sin(rz);
|
||||
let yy = y * cx - z * sx;
|
||||
let zz = y * sx + z * cx;
|
||||
let xx = x * cy + zz * sy;
|
||||
zz = -x * sy + zz * cy;
|
||||
const x2 = xx * cz - yy * sz;
|
||||
const y2 = xx * sz + yy * cz;
|
||||
return new THREE.Vector3(x2, y2, zz);
|
||||
}
|
||||
|
||||
export function useSceneCloudViewer(containerRef, options = {}) {
|
||||
const {
|
||||
getLayers = () => [],
|
||||
getSelectedId = () => null,
|
||||
getMode = () => "orbit",
|
||||
onTransform = () => {},
|
||||
onTransformGestureEnd = () => {},
|
||||
} = options;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0b1118);
|
||||
const camera = new THREE.PerspectiveCamera(55, 1, 0.001, 100000);
|
||||
camera.position.set(3.2, 2.4, 3.2);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
|
||||
renderer.domElement.style.width = "100%";
|
||||
renderer.domElement.style.height = "100%";
|
||||
renderer.domElement.style.display = "block";
|
||||
|
||||
let orbit = null;
|
||||
let transformControls = null;
|
||||
let animationId = 0;
|
||||
let layerGroup = new THREE.Group();
|
||||
scene.add(layerGroup);
|
||||
const pivots = new Map();
|
||||
let fittedOnce = false;
|
||||
|
||||
function resize() {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
const width = el.clientWidth;
|
||||
const height = el.clientHeight;
|
||||
camera.aspect = width / Math.max(height, 1);
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
orbit?.update();
|
||||
renderer.render(scene, camera);
|
||||
animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function clearLayers() {
|
||||
while (layerGroup.children.length) {
|
||||
const child = layerGroup.children[0];
|
||||
layerGroup.remove(child);
|
||||
child.traverse?.((obj) => {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) obj.material.dispose();
|
||||
});
|
||||
}
|
||||
pivots.clear();
|
||||
}
|
||||
|
||||
function pointSizeForCount(pointCount, radius) {
|
||||
const densityFactor = Math.min(1.4, Math.max(0.55, 9000 / Math.max(pointCount, 1)));
|
||||
return Math.max(radius * 0.012 * densityFactor, 0.004);
|
||||
}
|
||||
|
||||
function applyPivotTransform(pivot, transform) {
|
||||
pivot.position.set(
|
||||
transform?.x || 0,
|
||||
transform?.y || 0,
|
||||
transform?.z || 0,
|
||||
);
|
||||
pivot.rotation.set(
|
||||
transform?.rx || 0,
|
||||
transform?.ry || 0,
|
||||
transform?.rz || 0,
|
||||
"XYZ",
|
||||
);
|
||||
}
|
||||
|
||||
function sceneBounds(layers) {
|
||||
const box = new THREE.Box3();
|
||||
let any = false;
|
||||
for (const layer of layers) {
|
||||
if (!layer.points?.length || layer.visible === false) continue;
|
||||
const rx = layer.transform?.rx || 0;
|
||||
const ry = layer.transform?.ry || 0;
|
||||
const rz = layer.transform?.rz || 0;
|
||||
const tx = layer.transform?.x || 0;
|
||||
const ty = layer.transform?.y || 0;
|
||||
const tz = layer.transform?.z || 0;
|
||||
for (const p of layer.points) {
|
||||
const v = rotateXyz(p[0], p[1], p[2], rx, ry, rz);
|
||||
box.expandByPoint(v.add(new THREE.Vector3(tx, ty, tz)));
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
return any ? box : null;
|
||||
}
|
||||
|
||||
function fitCamera(layers) {
|
||||
const box = sceneBounds(layers);
|
||||
if (!box) return;
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const radius = Math.max(size.x, size.y, size.z) * 0.6 || 1;
|
||||
orbit.target.copy(center);
|
||||
camera.position.copy(center.clone().add(new THREE.Vector3(radius * 1.8, radius * 1.2, radius * 1.8)));
|
||||
camera.near = Math.max(radius / 1000, 0.0001);
|
||||
camera.far = radius * 100;
|
||||
camera.updateProjectionMatrix();
|
||||
orbit?.update();
|
||||
fittedOnce = true;
|
||||
}
|
||||
|
||||
function syncLayers(layers, { fit = false } = {}) {
|
||||
const selectedId = getSelectedId();
|
||||
const mode = getMode();
|
||||
if (transformControls) {
|
||||
transformControls.detach();
|
||||
}
|
||||
clearLayers();
|
||||
|
||||
const visible = layers.filter((layer) => layer.visible !== false && layer.points?.length);
|
||||
const box = sceneBounds(visible);
|
||||
const radius = box
|
||||
? Math.max(...box.getSize(new THREE.Vector3()).toArray()) * 0.6 || 1
|
||||
: 1;
|
||||
|
||||
for (const layer of visible) {
|
||||
const positions = new Float32Array(layer.points.length * 3);
|
||||
for (let i = 0; i < layer.points.length; i += 1) {
|
||||
positions[i * 3] = layer.points[i][0];
|
||||
positions[i * 3 + 1] = layer.points[i][1];
|
||||
positions[i * 3 + 2] = layer.points[i][2];
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
const points = new THREE.Points(
|
||||
geometry,
|
||||
new THREE.PointsMaterial({
|
||||
color: parseColor(layer.color),
|
||||
size: pointSizeForCount(layer.points.length, radius),
|
||||
sizeAttenuation: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const pivot = new THREE.Object3D();
|
||||
applyPivotTransform(pivot, layer.transform);
|
||||
pivot.userData.layerId = layer.id;
|
||||
pivot.add(points);
|
||||
layerGroup.add(pivot);
|
||||
pivots.set(layer.id, pivot);
|
||||
}
|
||||
|
||||
if (isGizmoMode(mode) && selectedId && pivots.has(selectedId) && transformControls) {
|
||||
transformControls.setMode(mode);
|
||||
transformControls.attach(pivots.get(selectedId));
|
||||
}
|
||||
|
||||
if ((fit || !fittedOnce) && visible.length) {
|
||||
fitCamera(visible);
|
||||
}
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
if (!orbit || !transformControls) return;
|
||||
const gizmo = isGizmoMode(mode);
|
||||
transformControls.enabled = gizmo;
|
||||
if (gizmo) {
|
||||
transformControls.setMode(mode);
|
||||
}
|
||||
const helper = transformControls.getHelper?.();
|
||||
if (helper) helper.visible = gizmo;
|
||||
orbit.enabled = !gizmo;
|
||||
const selectedId = getSelectedId();
|
||||
if (gizmo && selectedId && pivots.has(selectedId)) {
|
||||
transformControls.attach(pivots.get(selectedId));
|
||||
} else {
|
||||
transformControls.detach();
|
||||
}
|
||||
}
|
||||
|
||||
function applyTransforms(layers) {
|
||||
for (const layer of layers) {
|
||||
const pivot = pivots.get(layer.id);
|
||||
if (!pivot) continue;
|
||||
applyPivotTransform(pivot, layer.transform);
|
||||
}
|
||||
}
|
||||
|
||||
function setBackground(color) {
|
||||
scene.background = new THREE.Color(color);
|
||||
}
|
||||
|
||||
function resetCamera() {
|
||||
fitCamera(getLayers());
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
el.appendChild(renderer.domElement);
|
||||
|
||||
orbit = new OrbitControls(camera, renderer.domElement);
|
||||
orbit.enableDamping = true;
|
||||
|
||||
transformControls = new TransformControls(camera, renderer.domElement);
|
||||
transformControls.setMode("translate");
|
||||
transformControls.addEventListener("dragging-changed", (event) => {
|
||||
if (isGizmoMode(getMode())) {
|
||||
orbit.enabled = !event.value;
|
||||
if (!event.value) {
|
||||
onTransformGestureEnd?.(getMode() === "rotate" ? "Вращение" : "Перемещение");
|
||||
}
|
||||
} else {
|
||||
orbit.enabled = true;
|
||||
}
|
||||
});
|
||||
transformControls.addEventListener("objectChange", () => {
|
||||
const obj = transformControls.object;
|
||||
if (!obj?.userData?.layerId) return;
|
||||
onTransform(obj.userData.layerId, {
|
||||
x: obj.position.x,
|
||||
y: obj.position.y,
|
||||
z: obj.position.z,
|
||||
rx: obj.rotation.x,
|
||||
ry: obj.rotation.y,
|
||||
rz: obj.rotation.z,
|
||||
});
|
||||
});
|
||||
scene.add(transformControls.getHelper());
|
||||
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.7));
|
||||
const light = new THREE.DirectionalLight(0xffffff, 0.85);
|
||||
light.position.set(4, 6, 3);
|
||||
scene.add(light);
|
||||
scene.add(new THREE.AxesHelper(1.2));
|
||||
|
||||
resize();
|
||||
animate();
|
||||
window.addEventListener("resize", resize);
|
||||
syncLayers(getLayers(), { fit: true });
|
||||
setMode(getMode());
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animationId);
|
||||
window.removeEventListener("resize", resize);
|
||||
if (transformControls) {
|
||||
transformControls.detach();
|
||||
transformControls.dispose();
|
||||
}
|
||||
clearLayers();
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
return {
|
||||
syncLayers,
|
||||
applyTransforms,
|
||||
setMode,
|
||||
setBackground,
|
||||
resetCamera,
|
||||
resize,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const THEME_KEY = "dottosurface-theme";
|
||||
const theme = ref("dark");
|
||||
|
||||
function applyTheme(value) {
|
||||
document.documentElement.setAttribute("data-theme", value);
|
||||
localStorage.setItem(THEME_KEY, value);
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
theme.value = saved === "light" ? "light" : "dark";
|
||||
applyTheme(theme.value);
|
||||
}
|
||||
|
||||
watch(theme, applyTheme);
|
||||
|
||||
export function useTheme() {
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === "dark" ? "light" : "dark";
|
||||
}
|
||||
return { theme, toggleTheme };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { initTheme } from "./composables/useTheme";
|
||||
import "./styles/main.css";
|
||||
|
||||
initTheme();
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import PipelineView from "@/views/PipelineView.vue";
|
||||
import GeneratorView from "@/views/GeneratorView.vue";
|
||||
import DatasetView from "@/views/DatasetView.vue";
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: "/", name: "pipeline", component: PipelineView },
|
||||
{ path: "/generator", name: "generator", component: GeneratorView },
|
||||
{ path: "/dataset", name: "dataset", component: DatasetView },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { api } from "@/api/client";
|
||||
|
||||
export const useDatasetStore = defineStore("dataset", {
|
||||
state: () => ({
|
||||
busy: false,
|
||||
previewBusy: false,
|
||||
statusText: "Выберите .obj модель и нажмите «Сгенерировать».",
|
||||
count: 5,
|
||||
seed: 42,
|
||||
outputDir: "sonar_dataset",
|
||||
resolvedOutputDir: null,
|
||||
modelFile: null,
|
||||
modelFileName: "",
|
||||
objectScale: 1,
|
||||
beamCount: 45,
|
||||
lengthCount: 45,
|
||||
lastResult: null,
|
||||
selectedStem: null,
|
||||
previewPoints: [],
|
||||
previewStem: null,
|
||||
previewMeta: null,
|
||||
highlightClass: null,
|
||||
classLabels: { 0: "background", 1: "object" },
|
||||
classCounts: {},
|
||||
logLines: [],
|
||||
}),
|
||||
getters: {
|
||||
stats(state) {
|
||||
return state.lastResult?.stats || null;
|
||||
},
|
||||
written(state) {
|
||||
return state.lastResult?.written || [];
|
||||
},
|
||||
previewDir(state) {
|
||||
return state.resolvedOutputDir || state.outputDir || "sonar_dataset";
|
||||
},
|
||||
selectedScene(state) {
|
||||
const stem = state.selectedStem;
|
||||
if (!stem) return null;
|
||||
return (state.lastResult?.written || []).find((item) => item.stem === stem) || null;
|
||||
},
|
||||
canGenerate(state) {
|
||||
return !!state.modelFile && !state.busy;
|
||||
},
|
||||
classOptions(state) {
|
||||
const labels = state.classLabels || { 0: "background", 1: "object" };
|
||||
const counts = state.classCounts || {};
|
||||
const keys = Object.keys(labels).length
|
||||
? Object.keys(labels)
|
||||
: Object.keys(counts);
|
||||
return keys
|
||||
.map((k) => Number(k))
|
||||
.sort((a, b) => a - b)
|
||||
.map((id) => ({
|
||||
id,
|
||||
label: labels[String(id)] || labels[id] || `class ${id}`,
|
||||
count: counts[String(id)] ?? counts[id] ?? 0,
|
||||
}));
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
pushLog(line) {
|
||||
this.logLines.push(String(line));
|
||||
if (this.logLines.length > 200) {
|
||||
this.logLines = this.logLines.slice(-200);
|
||||
}
|
||||
},
|
||||
setHighlightClass(value) {
|
||||
if (value === null || value === undefined || value === "" || value === "all") {
|
||||
this.highlightClass = null;
|
||||
return;
|
||||
}
|
||||
this.highlightClass = Number(value);
|
||||
},
|
||||
applyPreviewPayload(result, writtenMeta = null) {
|
||||
this.previewStem = result.stem;
|
||||
this.previewPoints = result.points || [];
|
||||
this.classCounts = result.classCounts || {};
|
||||
if (result.classLabels) {
|
||||
this.classLabels = result.classLabels;
|
||||
}
|
||||
this.previewMeta = {
|
||||
pointCount: result.pointCount,
|
||||
previewCount: result.previewCount,
|
||||
visibility: writtenMeta?.visibility,
|
||||
hasObject: writtenMeta?.hasObject,
|
||||
objectPointCount: writtenMeta?.objectPointCount,
|
||||
classCounts: result.classCounts || {},
|
||||
};
|
||||
},
|
||||
setModelFile(file) {
|
||||
if (!file) {
|
||||
this.modelFile = null;
|
||||
this.modelFileName = "";
|
||||
this.statusText = "Выберите .obj модель и нажмите «Сгенерировать».";
|
||||
return;
|
||||
}
|
||||
const name = String(file.name || "");
|
||||
if (!name.toLowerCase().endsWith(".obj")) {
|
||||
this.modelFile = null;
|
||||
this.modelFileName = "";
|
||||
this.statusText = "Нужен файл формата .obj";
|
||||
return;
|
||||
}
|
||||
this.modelFile = file;
|
||||
this.modelFileName = name;
|
||||
this.statusText = `Модель: ${name}`;
|
||||
this.pushLog(`Выбрана модель: ${name}`);
|
||||
},
|
||||
async generate() {
|
||||
if (this.busy) return;
|
||||
if (!this.modelFile) {
|
||||
this.statusText = "Сначала выберите файл модели .obj";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.statusText = "Генерация датасета…";
|
||||
this.pushLog(
|
||||
`Старт: count=${this.count}, seed=${this.seed}, beams=${this.beamCount}, length=${this.lengthCount}, scale=${this.objectScale}, dir=${this.outputDir}, model=${this.modelFileName}`,
|
||||
);
|
||||
try {
|
||||
const result = await api.datasetGenerate({
|
||||
count: Number(this.count) || 5,
|
||||
seed: Number(this.seed) || 0,
|
||||
outputDir: String(this.outputDir || "sonar_dataset"),
|
||||
objectScale: Number(this.objectScale) || 1,
|
||||
beamCount: Number(this.beamCount) || 45,
|
||||
lengthCount: Number(this.lengthCount) || 45,
|
||||
modelFile: this.modelFile,
|
||||
});
|
||||
this.lastResult = result;
|
||||
this.resolvedOutputDir = result?.outputDir || null;
|
||||
const s = result?.stats || {};
|
||||
this.statusText = `Готово: ${result.count} сцен → ${result.outputDir}`;
|
||||
this.pushLog(
|
||||
`Модель: ${result.objectName || this.modelFileName} (${result.objectVertexCount || "?"} вершин), scale=${result.objectScale ?? this.objectScale}, ширина=${result.beamCount ?? this.beamCount}, длина=${result.lengthCount ?? this.lengthCount}. class 1 = object.`,
|
||||
);
|
||||
this.pushLog(
|
||||
`Записано ${result.count} сцен. С объектом: ${s.withObject}, без: ${s.withoutObject}.`,
|
||||
);
|
||||
this.pushLog(
|
||||
`Видимость: nearly_hidden=${s.nearly_hidden || 0}, partial=${s.partial || 0}, visible=${s.visible || 0}, absent=${s.absent || 0}.`,
|
||||
);
|
||||
for (const item of result.written || []) {
|
||||
this.pushLog(
|
||||
`${item.stem}: pts=${item.pointCount}, object=${item.objectPointCount}, ${item.visibility}`,
|
||||
);
|
||||
}
|
||||
|
||||
const initialStem =
|
||||
result?.preview?.stem ||
|
||||
result?.written?.find((item) => item.hasObject)?.stem ||
|
||||
result?.written?.[0]?.stem ||
|
||||
null;
|
||||
|
||||
if (result?.preview?.points?.length && result.preview.stem === initialStem) {
|
||||
this.selectedStem = initialStem;
|
||||
const meta = result.written?.find((w) => w.stem === initialStem);
|
||||
this.applyPreviewPayload(
|
||||
{
|
||||
stem: initialStem,
|
||||
points: result.preview.points,
|
||||
pointCount: result.preview.pointCount,
|
||||
previewCount: result.preview.points.length,
|
||||
classCounts: result.preview.classCounts || {},
|
||||
classLabels: result.preview.classLabels || result.classLabels,
|
||||
},
|
||||
meta,
|
||||
);
|
||||
} else if (initialStem) {
|
||||
await this.selectScene(initialStem);
|
||||
} else {
|
||||
this.selectedStem = null;
|
||||
this.previewStem = null;
|
||||
this.previewPoints = [];
|
||||
this.previewMeta = null;
|
||||
this.classCounts = {};
|
||||
}
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка: ${error.message}`;
|
||||
this.pushLog(`Ошибка: ${error.message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async selectScene(stem) {
|
||||
if (!stem || this.previewBusy) return;
|
||||
if (stem === this.previewStem && this.previewPoints?.length) {
|
||||
this.selectedStem = stem;
|
||||
return;
|
||||
}
|
||||
this.previewBusy = true;
|
||||
this.selectedStem = stem;
|
||||
this.statusText = `Загрузка превью: ${stem}…`;
|
||||
try {
|
||||
const result = await api.datasetPreview({
|
||||
stem,
|
||||
outputDir: String(this.previewDir),
|
||||
});
|
||||
const writtenMeta = this.written.find((item) => item.stem === result.stem);
|
||||
this.applyPreviewPayload(result, writtenMeta);
|
||||
this.statusText = `Превью: ${result.stem} (${result.pointCount} точек)`;
|
||||
this.pushLog(`Превью загружено: ${result.stem} (${result.pointCount} pts)`);
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка превью: ${error.message}`;
|
||||
this.pushLog(`Ошибка превью: ${error.message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
this.previewBusy = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,517 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { api, exportFilename, parseCloudPoints } from "@/api/client";
|
||||
|
||||
let layerSeq = 1;
|
||||
const MAX_HISTORY = 80;
|
||||
const IMPORT_COLORS = {
|
||||
obj: "#f472b6",
|
||||
ply: "#38bdf8",
|
||||
xyz: "#a3e635",
|
||||
};
|
||||
|
||||
function defaultsFromSchema(schema) {
|
||||
const params = {};
|
||||
for (const field of schema?.params || []) {
|
||||
params[field.key] = field.default;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function normalizeTransform(transform) {
|
||||
return {
|
||||
x: Number(transform?.x) || 0,
|
||||
y: Number(transform?.y) || 0,
|
||||
z: Number(transform?.z) || 0,
|
||||
rx: Number(transform?.rx) || 0,
|
||||
ry: Number(transform?.ry) || 0,
|
||||
rz: Number(transform?.rz) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function rotateXyz(x, y, z, rx, ry, rz) {
|
||||
const cx = Math.cos(rx);
|
||||
const sx = Math.sin(rx);
|
||||
const cy = Math.cos(ry);
|
||||
const sy = Math.sin(ry);
|
||||
const cz = Math.cos(rz);
|
||||
const sz = Math.sin(rz);
|
||||
let yy = y * cx - z * sx;
|
||||
let zz = y * sx + z * cx;
|
||||
let xx = x * cy + zz * sy;
|
||||
zz = -x * sy + zz * cy;
|
||||
const x2 = xx * cz - yy * sz;
|
||||
const y2 = xx * sz + yy * cz;
|
||||
return [x2, y2, zz];
|
||||
}
|
||||
|
||||
function applyTransform(points, transform) {
|
||||
const t = normalizeTransform(transform);
|
||||
return points.map((p) => {
|
||||
const [x, y, z] = rotateXyz(p[0], p[1], p[2], t.rx, t.ry, t.rz);
|
||||
return [x + t.x, y + t.y, z + t.z];
|
||||
});
|
||||
}
|
||||
|
||||
function cloneLayers(layers) {
|
||||
// Pinia gives reactive proxies; structuredClone cannot clone Proxies.
|
||||
return JSON.parse(JSON.stringify(layers || []));
|
||||
}
|
||||
|
||||
function makeSnapshot(label, layers, selectedLayerId) {
|
||||
return {
|
||||
id: `h-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
label: String(label || "Действие"),
|
||||
selectedLayerId,
|
||||
layers: cloneLayers(layers),
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export const useGeneratorStore = defineStore("generator", {
|
||||
state: () => ({
|
||||
busy: false,
|
||||
statusText: "Добавьте объект или поверхность.",
|
||||
catalog: [],
|
||||
colors: {},
|
||||
layers: [],
|
||||
selectedLayerId: null,
|
||||
interactionMode: "orbit",
|
||||
translateStep: 0.05,
|
||||
rotateStepDeg: 5,
|
||||
exportFormat: "xyz",
|
||||
viewerRevision: 0,
|
||||
history: [makeSnapshot("Начало", [], null)],
|
||||
historyIndex: 0,
|
||||
_restoring: false,
|
||||
}),
|
||||
getters: {
|
||||
selectedLayer(state) {
|
||||
return state.layers.find((layer) => layer.id === state.selectedLayerId) || null;
|
||||
},
|
||||
objectTypes(state) {
|
||||
return state.catalog.filter((item) => item.kind === "object");
|
||||
},
|
||||
surfaceTypes(state) {
|
||||
return state.catalog.filter((item) => item.kind === "surface");
|
||||
},
|
||||
schemaFor() {
|
||||
return (kind, type) => this.catalog.find((item) => item.kind === kind && item.type === type) || null;
|
||||
},
|
||||
visibleLayers(state) {
|
||||
return state.layers.filter((layer) => layer.visible !== false && layer.points?.length);
|
||||
},
|
||||
canUndo(state) {
|
||||
return state.historyIndex > 0;
|
||||
},
|
||||
canRedo(state) {
|
||||
return state.historyIndex < state.history.length - 1;
|
||||
},
|
||||
historyEntries(state) {
|
||||
return state.history;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async bootstrap() {
|
||||
const payload = await api.generatorCatalog();
|
||||
this.catalog = payload.layers || [];
|
||||
this.colors = payload.colors || {};
|
||||
this.statusText = "Каталог загружен. Добавьте слой.";
|
||||
},
|
||||
bumpViewer() {
|
||||
this.viewerRevision += 1;
|
||||
},
|
||||
restoreSnapshot(snapshot) {
|
||||
if (!snapshot) return;
|
||||
this._restoring = true;
|
||||
try {
|
||||
this.layers = cloneLayers(snapshot.layers);
|
||||
this.selectedLayerId = snapshot.selectedLayerId;
|
||||
this.bumpViewer();
|
||||
} catch (error) {
|
||||
console.error("restoreSnapshot failed", error);
|
||||
this.statusText = `Не удалось восстановить историю: ${error.message}`;
|
||||
} finally {
|
||||
this._restoring = false;
|
||||
}
|
||||
},
|
||||
/** Record current scene as a named history step (Photoshop-style). */
|
||||
recordHistory(label = "Действие") {
|
||||
if (this._restoring) return;
|
||||
try {
|
||||
const snapshot = makeSnapshot(label, this.layers, this.selectedLayerId);
|
||||
this.history = this.history.slice(0, this.historyIndex + 1);
|
||||
this.history.push(snapshot);
|
||||
if (this.history.length > MAX_HISTORY) {
|
||||
this.history.splice(0, this.history.length - MAX_HISTORY);
|
||||
}
|
||||
this.historyIndex = this.history.length - 1;
|
||||
} catch (error) {
|
||||
console.error("recordHistory failed", error);
|
||||
this.statusText = `История не записана: ${error.message}`;
|
||||
}
|
||||
},
|
||||
undo() {
|
||||
if (!this.canUndo) {
|
||||
this.statusText = "Нечего отменять.";
|
||||
return;
|
||||
}
|
||||
this.historyIndex -= 1;
|
||||
const snap = this.history[this.historyIndex];
|
||||
this.restoreSnapshot(snap);
|
||||
this.statusText = `История: ${snap.label}`;
|
||||
},
|
||||
redo() {
|
||||
if (!this.canRedo) {
|
||||
this.statusText = "Нечего повторить.";
|
||||
return;
|
||||
}
|
||||
this.historyIndex += 1;
|
||||
const snap = this.history[this.historyIndex];
|
||||
this.restoreSnapshot(snap);
|
||||
this.statusText = `История: ${snap.label}`;
|
||||
},
|
||||
jumpToHistory(index) {
|
||||
const i = Math.floor(Number(index));
|
||||
if (!Number.isFinite(i) || i < 0 || i >= this.history.length) return;
|
||||
if (i === this.historyIndex) return;
|
||||
this.historyIndex = i;
|
||||
const snap = this.history[i];
|
||||
this.restoreSnapshot(snap);
|
||||
this.statusText = `История: ${snap.label}`;
|
||||
},
|
||||
selectLayer(id) {
|
||||
this.selectedLayerId = id;
|
||||
},
|
||||
setInteractionMode(mode) {
|
||||
if (mode === "translate" || mode === "rotate") {
|
||||
this.interactionMode = mode;
|
||||
} else {
|
||||
this.interactionMode = "orbit";
|
||||
}
|
||||
},
|
||||
setTranslateStep(step) {
|
||||
const value = Number(step);
|
||||
this.translateStep = Number.isFinite(value) && value > 0 ? value : 0.05;
|
||||
},
|
||||
setRotateStepDeg(step) {
|
||||
const value = Number(step);
|
||||
this.rotateStepDeg = Number.isFinite(value) && value > 0 ? value : 5;
|
||||
},
|
||||
setExportFormat(format) {
|
||||
const allowed = new Set(["xyz", "ply", "obj", "npy"]);
|
||||
this.exportFormat = allowed.has(format) ? format : "xyz";
|
||||
},
|
||||
updateSelectedParams(partial) {
|
||||
const layer = this.selectedLayer;
|
||||
if (!layer || layer.type === "imported") return;
|
||||
layer.params = { ...layer.params, ...partial };
|
||||
},
|
||||
setLayerVisible(id, visible) {
|
||||
const layer = this.layers.find((item) => item.id === id);
|
||||
if (!layer) return;
|
||||
layer.visible = !!visible;
|
||||
this.bumpViewer();
|
||||
this.recordHistory(visible ? "Показать слой" : "Скрыть слой");
|
||||
},
|
||||
setLayerTransform(id, transform, { refresh = true, recordHistory = false, historyLabel = "Положение слоя" } = {}) {
|
||||
const layer = this.layers.find((item) => item.id === id);
|
||||
if (!layer) return;
|
||||
layer.transform = normalizeTransform({
|
||||
...layer.transform,
|
||||
...transform,
|
||||
});
|
||||
if (refresh) this.bumpViewer();
|
||||
if (recordHistory) this.recordHistory(historyLabel);
|
||||
},
|
||||
endTransformGesture(label = "Перемещение") {
|
||||
this.recordHistory(label);
|
||||
},
|
||||
nudgeSelected(dx, dy, dz) {
|
||||
const layer = this.selectedLayer;
|
||||
if (!layer) return;
|
||||
const t = normalizeTransform(layer.transform);
|
||||
this.setLayerTransform(layer.id, {
|
||||
...t,
|
||||
x: t.x + dx,
|
||||
y: t.y + dy,
|
||||
z: t.z + dz,
|
||||
}, { refresh: true, recordHistory: true, historyLabel: "Перемещение" });
|
||||
},
|
||||
nudgeSelectedRotation(drx, dry, drz) {
|
||||
const layer = this.selectedLayer;
|
||||
if (!layer) return;
|
||||
const t = normalizeTransform(layer.transform);
|
||||
this.setLayerTransform(layer.id, {
|
||||
...t,
|
||||
rx: t.rx + drx,
|
||||
ry: t.ry + dry,
|
||||
rz: t.rz + drz,
|
||||
}, { refresh: true, recordHistory: true, historyLabel: "Вращение" });
|
||||
},
|
||||
removeLayer(id) {
|
||||
this.layers = this.layers.filter((layer) => layer.id !== id);
|
||||
if (this.selectedLayerId === id) {
|
||||
this.selectedLayerId = this.layers[0]?.id || null;
|
||||
}
|
||||
this.statusText = "Слой удалён.";
|
||||
this.bumpViewer();
|
||||
this.recordHistory("Удаление слоя");
|
||||
},
|
||||
async addLayer(kind, type) {
|
||||
const schema = this.schemaFor(kind, type);
|
||||
if (!schema) {
|
||||
this.statusText = "Неизвестный тип слоя.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.statusText = `Генерация: ${schema.label}…`;
|
||||
try {
|
||||
const params = defaultsFromSchema(schema);
|
||||
const result = await api.generatorLayer(kind, type, params);
|
||||
const id = `layer-${layerSeq++}`;
|
||||
const colorKey = `${kind}:${type}`;
|
||||
this.layers.push({
|
||||
id,
|
||||
name: `${schema.label} ${layerSeq - 1}`,
|
||||
kind,
|
||||
type,
|
||||
label: result.label || schema.label,
|
||||
params: result.params || params,
|
||||
transform: { x: 0, y: 0, z: kind === "object" ? 0.4 : 0, rx: 0, ry: 0, rz: 0 },
|
||||
points: result.points || [],
|
||||
visible: true,
|
||||
color: result.color || this.colors[colorKey] || "#7dd3fc",
|
||||
});
|
||||
this.selectedLayerId = id;
|
||||
this.statusText = `${schema.label}: ${result.pointCount} точек.`;
|
||||
this.bumpViewer();
|
||||
this.recordHistory(`Добавление: ${schema.label}`);
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка генерации: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async importCloudFile(file) {
|
||||
if (!file) return;
|
||||
this.busy = true;
|
||||
this.statusText = `Загрузка: ${file.name}…`;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const { format, points } = parseCloudPoints(text, file.name);
|
||||
if (!points.length) {
|
||||
this.statusText = `В файле нет точек (${format.toUpperCase()}).`;
|
||||
return;
|
||||
}
|
||||
const id = `layer-${layerSeq++}`;
|
||||
const stem = String(file.name || format).replace(/\.[^.]+$/, "") || format;
|
||||
this.layers.push({
|
||||
id,
|
||||
name: stem,
|
||||
kind: "object",
|
||||
type: "imported",
|
||||
label: format.toUpperCase(),
|
||||
params: { count: points.length, seed: 0, noise: 0, sourceFormat: format },
|
||||
transform: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 },
|
||||
points,
|
||||
visible: true,
|
||||
color: IMPORT_COLORS[format] || "#f472b6",
|
||||
});
|
||||
this.selectedLayerId = id;
|
||||
this.statusText = `${format.toUpperCase()} загружен: ${points.length} точек.`;
|
||||
this.bumpViewer();
|
||||
this.recordHistory(`Импорт ${format.toUpperCase()}`);
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка загрузки: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async importObjFile(file) {
|
||||
return this.importCloudFile(file);
|
||||
},
|
||||
async regenerateSelected() {
|
||||
const layer = this.selectedLayer;
|
||||
if (!layer) return;
|
||||
if (layer.type === "imported") {
|
||||
this.statusText = "Импортированный слой нельзя перегенерировать — только сдвинуть/сохранить.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.statusText = `Перегенерация: ${layer.name}…`;
|
||||
try {
|
||||
const result = await api.generatorLayer(layer.kind, layer.type, layer.params);
|
||||
layer.params = result.params || layer.params;
|
||||
layer.points = result.points || [];
|
||||
layer.color = result.color || layer.color;
|
||||
this.statusText = `${layer.name}: ${result.pointCount} точек.`;
|
||||
this.bumpViewer();
|
||||
this.recordHistory(`Перегенерация: ${layer.name}`);
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async resolveIntersections() {
|
||||
if (!this.layers.length) {
|
||||
this.statusText = "Нет слоёв для обработки.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
this.statusText = "Удаление пересечений…";
|
||||
try {
|
||||
const payload = {
|
||||
layers: this.layers.map((layer) => ({
|
||||
id: layer.id,
|
||||
kind: layer.kind,
|
||||
type: layer.type,
|
||||
params: layer.params,
|
||||
transform: layer.transform,
|
||||
points: layer.points,
|
||||
label: layer.label,
|
||||
color: layer.color,
|
||||
})),
|
||||
clipSurfaceInsideObjects: true,
|
||||
clipObjectsVsObjects: true,
|
||||
};
|
||||
const result = await api.generatorResolveIntersections(payload);
|
||||
const byId = Object.fromEntries((result.layers || []).map((layer) => [layer.id, layer]));
|
||||
for (const layer of this.layers) {
|
||||
const updated = byId[layer.id];
|
||||
if (!updated) continue;
|
||||
layer.points = updated.points || [];
|
||||
layer.params = updated.params || layer.params;
|
||||
}
|
||||
this.statusText = `Пересечения удалены (снято точек: ${result.removedTotal || 0}).`;
|
||||
this.bumpViewer();
|
||||
this.recordHistory("Удаление пересечений");
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка пересечений: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
layerExportPayload(layer) {
|
||||
return {
|
||||
id: layer.id,
|
||||
kind: layer.kind,
|
||||
type: layer.type,
|
||||
name: layer.name,
|
||||
label: layer.label,
|
||||
params: layer.params,
|
||||
transform: layer.transform,
|
||||
points: layer.points,
|
||||
color: layer.color,
|
||||
};
|
||||
},
|
||||
layerClassLabel(layer) {
|
||||
const type = String(layer?.type || "").toLowerCase();
|
||||
if (type === "pipe") return 1;
|
||||
const name = String(layer?.name || layer?.label || "").toLowerCase();
|
||||
if (name.includes("pipe") || name.includes("труб")) return 1;
|
||||
return 0;
|
||||
},
|
||||
async saveSelectedLayer() {
|
||||
const layer = this.selectedLayer;
|
||||
if (!layer?.points?.length) {
|
||||
this.statusText = "Выберите слой с точками.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
try {
|
||||
const filename = exportFilename(
|
||||
`${layer.type || "layer"}_${String(layer.id || "").replace(/^layer-/, "") || "cloud"}`,
|
||||
this.exportFormat,
|
||||
);
|
||||
if (this.exportFormat === "npy") {
|
||||
await api.generatorExport({
|
||||
layers: [this.layerExportPayload(layer)],
|
||||
format: "npy",
|
||||
filename,
|
||||
});
|
||||
} else {
|
||||
const worldPoints = applyTransform(layer.points, layer.transform);
|
||||
await api.generatorExport({
|
||||
points: worldPoints,
|
||||
format: this.exportFormat,
|
||||
filename,
|
||||
});
|
||||
}
|
||||
this.statusText = `Скачан файл: ${filename}`;
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка сохранения: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async saveScene() {
|
||||
const layers = this.visibleLayers;
|
||||
if (!layers.length) {
|
||||
this.statusText = "Нет видимых слоёв.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
try {
|
||||
const filename = exportFilename("scene_cloud", this.exportFormat);
|
||||
if (this.exportFormat === "npy") {
|
||||
await api.generatorExport({
|
||||
layers: layers.map((layer) => this.layerExportPayload(layer)),
|
||||
format: "npy",
|
||||
filename,
|
||||
});
|
||||
} else {
|
||||
const merged = [];
|
||||
for (const layer of layers) {
|
||||
merged.push(...applyTransform(layer.points, layer.transform));
|
||||
}
|
||||
await api.generatorExport({
|
||||
points: merged,
|
||||
format: this.exportFormat,
|
||||
filename,
|
||||
});
|
||||
}
|
||||
this.statusText = `Скачан файл: ${filename}`;
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка сохранения сцены: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async saveAllLayersSeparately() {
|
||||
const layers = this.layers.filter((layer) => layer.points?.length);
|
||||
if (!layers.length) {
|
||||
this.statusText = "Нет слоёв для сохранения.";
|
||||
return;
|
||||
}
|
||||
this.busy = true;
|
||||
try {
|
||||
for (const layer of layers) {
|
||||
const filename = exportFilename(
|
||||
`${layer.type || "layer"}_${String(layer.id || "").replace(/^layer-/, "") || "cloud"}`,
|
||||
this.exportFormat,
|
||||
);
|
||||
if (this.exportFormat === "npy") {
|
||||
await api.generatorExport({
|
||||
layers: [this.layerExportPayload(layer)],
|
||||
format: "npy",
|
||||
filename,
|
||||
});
|
||||
} else {
|
||||
const worldPoints = applyTransform(layer.points, layer.transform);
|
||||
await api.generatorExport({
|
||||
points: worldPoints,
|
||||
format: this.exportFormat,
|
||||
filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.statusText = `Скачано файлов: ${layers.length}.`;
|
||||
} catch (error) {
|
||||
this.statusText = `Ошибка пакетного сохранения: ${error.message}`;
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { api } from "@/api/client";
|
||||
import {
|
||||
filterPaletteGroupedModel,
|
||||
reconstructionPaletteModel,
|
||||
} from "@/catalog/pipelineUiCatalog";
|
||||
|
||||
function configFromCards(stageCards) {
|
||||
const preprocessPlugins = [];
|
||||
const stageDefaults = {};
|
||||
let reconstructionPlugin = "surface_fallback";
|
||||
for (const card of stageCards) {
|
||||
if (!card.enabled) continue;
|
||||
if (card.family === "preprocess") preprocessPlugins.push(card.id);
|
||||
if (card.family === "reconstruction") reconstructionPlugin = card.id;
|
||||
if (card.defaults) stageDefaults[card.id] = card.defaults;
|
||||
}
|
||||
return {
|
||||
profile: "desktop_debug",
|
||||
preprocessPlugins,
|
||||
reconstructionPlugin,
|
||||
stageDefaults,
|
||||
};
|
||||
}
|
||||
|
||||
function cardsFromConfig(config, stageMetaById = {}) {
|
||||
const cards = [];
|
||||
const stageDefaults = config.stageDefaults || {};
|
||||
for (const stageId of config.preprocessPlugins || []) {
|
||||
const meta = stageMetaById[stageId] || {};
|
||||
cards.push({
|
||||
id: stageId,
|
||||
title: meta.title || stageId,
|
||||
category: meta.category || "Custom",
|
||||
family: "preprocess",
|
||||
hint: meta.hint || "",
|
||||
defaults: stageDefaults[stageId] ?? meta.defaults ?? "",
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
const reconId = config.reconstructionPlugin || "surface_fallback";
|
||||
const reconMeta = stageMetaById[reconId] || {};
|
||||
cards.push({
|
||||
id: reconId,
|
||||
title: reconMeta.title || reconId,
|
||||
category: reconMeta.category || "Реконструкция",
|
||||
family: "reconstruction",
|
||||
hint: reconMeta.hint || "",
|
||||
defaults: stageDefaults[reconId] ?? reconMeta.defaults ?? "",
|
||||
enabled: true,
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
|
||||
function metaForStage(stageId, stageMetaById) {
|
||||
return stageMetaById[stageId] || {
|
||||
id: stageId,
|
||||
title: stageId,
|
||||
category: "Custom",
|
||||
family: "preprocess",
|
||||
hint: "",
|
||||
defaults: "",
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable signature of pipeline composition + stage parameters. */
|
||||
function pipelineFingerprint(stageCards) {
|
||||
return JSON.stringify(
|
||||
(stageCards || []).map((card) => ({
|
||||
id: card.id,
|
||||
family: card.family,
|
||||
enabled: !!card.enabled,
|
||||
defaults: card.defaults || "",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export const usePipelineStore = defineStore("pipeline", {
|
||||
state: () => ({
|
||||
busy: false,
|
||||
statusText: "Загрузите облако или сгенерируйте демо.",
|
||||
stageCards: [],
|
||||
selectedStageIndex: -1,
|
||||
surfaceVisible: true,
|
||||
demoSurfaceType: "Сфера",
|
||||
demoSurfaceTypes: ["Сфера", "Тор", "Волна", "Дно реки + труба"],
|
||||
wizardProfile: "general",
|
||||
wizardGoal: "balanced",
|
||||
presetItems: [],
|
||||
metrics: {
|
||||
inputPoints: 0,
|
||||
afterPreprocess: 0,
|
||||
triangles: 0,
|
||||
reconstructMs: 0,
|
||||
clusters: 0,
|
||||
removedPoints: 0,
|
||||
},
|
||||
stageMetrics: [],
|
||||
chainHealth: "",
|
||||
recommendation: "",
|
||||
warningsList: [],
|
||||
paletteGroups: filterPaletteGroupedModel(),
|
||||
reconstructionOptions: reconstructionPaletteModel(),
|
||||
snapshots: [],
|
||||
currentFile: null,
|
||||
usingDemoInput: false,
|
||||
lastResult: null,
|
||||
viewerPoints: [],
|
||||
viewerTriangles: [],
|
||||
geometryUrl: null,
|
||||
stageMetaById: {},
|
||||
/** Fingerprint of stageCards after last successful apply/run; null = never applied. */
|
||||
appliedFingerprint: null,
|
||||
}),
|
||||
getters: {
|
||||
selectedStage(state) {
|
||||
if (state.selectedStageIndex < 0 || state.selectedStageIndex >= state.stageCards.length) {
|
||||
return {};
|
||||
}
|
||||
return state.stageCards[state.selectedStageIndex];
|
||||
},
|
||||
pipelineConfig(state) {
|
||||
return configFromCards(state.stageCards);
|
||||
},
|
||||
reconstructionMethod(state) {
|
||||
const recon = state.stageCards.find((c) => c.family === "reconstruction");
|
||||
return recon ? recon.id : "surface_fallback";
|
||||
},
|
||||
pipelineNeedsApply(state) {
|
||||
return state.appliedFingerprint !== pipelineFingerprint(state.stageCards);
|
||||
},
|
||||
applyButtonLabel(state) {
|
||||
return state.appliedFingerprint !== null
|
||||
&& state.appliedFingerprint === pipelineFingerprint(state.stageCards)
|
||||
? "Применено"
|
||||
: "Применить";
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async bootstrap() {
|
||||
const [presets, defaultConfig, catalog] = await Promise.all([
|
||||
api.presets(),
|
||||
api.defaultConfig(),
|
||||
api.catalog(),
|
||||
]);
|
||||
this.stageMetaById = Object.fromEntries(
|
||||
(catalog.stageMeta || []).map((item) => [item.id, item]),
|
||||
);
|
||||
this.presetItems = presets;
|
||||
this.stageCards = cardsFromConfig(defaultConfig, this.stageMetaById);
|
||||
await this.validateChain();
|
||||
},
|
||||
async validateChain() {
|
||||
const result = await api.validateConfig(this.pipelineConfig);
|
||||
this.chainHealth = result.chainHealth;
|
||||
this.recommendation = result.recommendation;
|
||||
this.warningsList = result.warningsList || [];
|
||||
if (result.stageCards) this.stageCards = result.stageCards;
|
||||
},
|
||||
setSelectedStageIndex(index) {
|
||||
this.selectedStageIndex = index;
|
||||
},
|
||||
addStage(stageId) {
|
||||
const meta = metaForStage(stageId, this.stageMetaById);
|
||||
const family = meta.family || (stageId.startsWith("pcl_") || stageId === "downsample_dense" || stageId === "keep_largest_cluster" ? "preprocess" : "preprocess");
|
||||
if (family === "reconstruction") {
|
||||
const idx = this.stageCards.findIndex((c) => c.family === "reconstruction");
|
||||
if (idx >= 0) {
|
||||
this.stageCards[idx] = {
|
||||
id: stageId,
|
||||
title: meta.title,
|
||||
category: meta.category,
|
||||
family: "reconstruction",
|
||||
hint: meta.hint,
|
||||
defaults: meta.defaults,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const reconIndex = this.stageCards.findIndex((c) => c.family === "reconstruction");
|
||||
const insertAt = reconIndex >= 0 ? reconIndex : this.stageCards.length;
|
||||
this.stageCards.splice(insertAt, 0, {
|
||||
id: stageId,
|
||||
title: meta.title,
|
||||
category: meta.category,
|
||||
family: "preprocess",
|
||||
hint: meta.hint,
|
||||
defaults: meta.defaults,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
this.validateChain();
|
||||
},
|
||||
removeStage(index) {
|
||||
const card = this.stageCards[index];
|
||||
if (!card || card.family === "reconstruction") return;
|
||||
this.stageCards.splice(index, 1);
|
||||
if (this.selectedStageIndex >= this.stageCards.length) {
|
||||
this.selectedStageIndex = this.stageCards.length - 1;
|
||||
}
|
||||
this.validateChain();
|
||||
},
|
||||
moveStage(from, to) {
|
||||
if (from === to) return;
|
||||
const card = this.stageCards[from];
|
||||
if (!card || card.family === "reconstruction") return;
|
||||
const reconIndex = this.stageCards.findIndex((c) => c.family === "reconstruction");
|
||||
const target = Math.min(to, reconIndex >= 0 ? reconIndex : this.stageCards.length - 1);
|
||||
this.stageCards.splice(from, 1);
|
||||
const insertAt = from < target ? target - 1 : target;
|
||||
this.stageCards.splice(insertAt, 0, card);
|
||||
this.selectedStageIndex = insertAt;
|
||||
this.validateChain();
|
||||
},
|
||||
setStageEnabled(index, enabled) {
|
||||
if (this.stageCards[index]) {
|
||||
this.stageCards[index].enabled = enabled;
|
||||
this.validateChain();
|
||||
}
|
||||
},
|
||||
setStageDefaults(index, defaultsText) {
|
||||
if (this.stageCards[index]) {
|
||||
this.stageCards[index].defaults = defaultsText;
|
||||
this.validateChain();
|
||||
}
|
||||
},
|
||||
async defaultsForStage(stageId) {
|
||||
const response = await api.stageDefaults(stageId);
|
||||
return response.defaults;
|
||||
},
|
||||
async applyPreset(presetId) {
|
||||
const preset = this.presetItems.find((p) => p.idValue === presetId || p.id === presetId);
|
||||
if (!preset) throw new Error(`Preset not found: ${presetId}`);
|
||||
const config = preset.config || preset;
|
||||
this.stageCards = cardsFromConfig(config, this.stageMetaById);
|
||||
await this.validateChain();
|
||||
this.statusText = `Пресет '${preset.title || presetId}' загружен. Нажмите 'Применить' для запуска.`;
|
||||
},
|
||||
async applyWizard() {
|
||||
const result = await api.wizard(this.wizardProfile, this.wizardGoal);
|
||||
this.stageCards = result.stageCards;
|
||||
this.chainHealth = result.chainHealth;
|
||||
this.recommendation = result.recommendation;
|
||||
this.warningsList = result.warningsList || [];
|
||||
this.statusText = `Wizard предложил пресет '${result.title}'.`;
|
||||
},
|
||||
setDemoSurfaceType(value) {
|
||||
this.demoSurfaceType = value;
|
||||
},
|
||||
setSurfaceVisible(value) {
|
||||
this.surfaceVisible = value;
|
||||
},
|
||||
async runPipeline({ useDemo = false, geometryFormat = "json" } = {}) {
|
||||
const runWithDemo = useDemo || (this.usingDemoInput && !this.currentFile);
|
||||
if (!runWithDemo && !this.currentFile) {
|
||||
this.statusText = "Загрузите файл или сгенерируйте демо.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.busy = true;
|
||||
try {
|
||||
if (useDemo) {
|
||||
this.usingDemoInput = true;
|
||||
}
|
||||
const result = await api.runPipeline({
|
||||
file: runWithDemo ? null : this.currentFile,
|
||||
demoSurface: runWithDemo ? this.demoSurfaceType : null,
|
||||
config: this.pipelineConfig,
|
||||
geometryFormat,
|
||||
});
|
||||
this.lastResult = result;
|
||||
this.metrics = result.metrics || {
|
||||
inputPoints: result.inputPoints,
|
||||
afterPreprocess: result.afterPreprocess,
|
||||
triangles: result.triangles,
|
||||
reconstructMs: result.reconstructionMs,
|
||||
clusters: result.clusters || 0,
|
||||
removedPoints: result.removedPoints || 0,
|
||||
};
|
||||
this.stageMetrics = result.preprocessStepMetrics || [];
|
||||
this.chainHealth = result.chainHealth || "";
|
||||
this.recommendation = result.recommendation || "";
|
||||
this.warningsList = result.warningsList || [];
|
||||
if (result.geometryUrl) {
|
||||
this.geometryUrl = result.geometryUrl;
|
||||
this.viewerPoints = [];
|
||||
this.viewerTriangles = [];
|
||||
} else {
|
||||
this.geometryUrl = null;
|
||||
this.viewerPoints = result.points || [];
|
||||
this.viewerTriangles = result.triangleIndices || [];
|
||||
}
|
||||
this.appliedFingerprint = pipelineFingerprint(this.stageCards);
|
||||
this.statusText = result.stdout || "Pipeline completed.";
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async loadGeometryFromUrl(workId) {
|
||||
const buffer = await api.fetchGeometry(workId);
|
||||
const view = new DataView(buffer);
|
||||
let offset = 0;
|
||||
const pointCount = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
const points = [];
|
||||
for (let i = 0; i < pointCount; i += 1) {
|
||||
points.push([
|
||||
view.getFloat32(offset, true),
|
||||
view.getFloat32(offset + 4, true),
|
||||
view.getFloat32(offset + 8, true),
|
||||
]);
|
||||
offset += 12;
|
||||
}
|
||||
const triCount = view.getUint32(offset, true);
|
||||
offset += 4;
|
||||
const triangles = [];
|
||||
for (let i = 0; i < triCount; i += 1) {
|
||||
triangles.push([
|
||||
view.getInt32(offset, true),
|
||||
view.getInt32(offset + 4, true),
|
||||
view.getInt32(offset + 8, true),
|
||||
]);
|
||||
offset += 12;
|
||||
}
|
||||
this.viewerPoints = points;
|
||||
this.viewerTriangles = triangles;
|
||||
},
|
||||
setCurrentFile(file) {
|
||||
this.currentFile = file;
|
||||
if (file) {
|
||||
this.usingDemoInput = false;
|
||||
}
|
||||
this.statusText = file ? `Выбран файл: ${file.name}` : "Файл не выбран.";
|
||||
},
|
||||
saveSnapshot(name) {
|
||||
const slot = name || `конф-${this.snapshots.length + 1}`;
|
||||
this.snapshots = [
|
||||
...this.snapshots.filter((s) => s.name !== slot),
|
||||
{
|
||||
name: slot,
|
||||
stageCards: JSON.parse(JSON.stringify(this.stageCards)),
|
||||
metrics: { ...this.metrics },
|
||||
},
|
||||
];
|
||||
this.statusText = `Конфигурация «${slot}» сохранена (до перезагрузки страницы).`;
|
||||
},
|
||||
loadSnapshot(name) {
|
||||
const snapshot = this.snapshots.find((s) => s.name === name);
|
||||
if (!snapshot) return;
|
||||
this.stageCards = JSON.parse(JSON.stringify(snapshot.stageCards));
|
||||
this.validateChain();
|
||||
this.statusText = `Конфигурация «${name}» загружена.`;
|
||||
},
|
||||
async saveCurrentPreset(title) {
|
||||
const stages = this.stageCards.map((card) => ({
|
||||
id: card.id,
|
||||
family: card.family,
|
||||
enabled: card.enabled,
|
||||
defaults: card.defaults || "",
|
||||
}));
|
||||
await api.saveUserPreset({ title, stages });
|
||||
this.presetItems = await api.presets();
|
||||
this.statusText = `Пресет '${title}' сохранён.`;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--panel-bg: #141821;
|
||||
--card-bg: #111a2c;
|
||||
--card-border: #33486f;
|
||||
--header-bg: #152231;
|
||||
--header-border: #33486f;
|
||||
--health-bg: #173528;
|
||||
--health-text: #9be7b5;
|
||||
--chain-selected-border: #6ea8ff;
|
||||
--chain-idle-border: #384866;
|
||||
--chain-enabled-bg: #1f2a3d;
|
||||
--chain-disabled-bg: #23272f;
|
||||
--control-bg: #23324d;
|
||||
--control-border: #4a6494;
|
||||
--control-text: #eaf0ff;
|
||||
--button-bg: #2c3f61;
|
||||
--button-border: #5574a9;
|
||||
--button-text: #eef4ff;
|
||||
--primary-button-bg: #ffb020;
|
||||
--primary-button-text: #142033;
|
||||
--summary-primary: #eaf0ff;
|
||||
--summary-secondary: #cde0ff;
|
||||
--summary-recommendation: #9ec3ff;
|
||||
--palette-filter-bg: #26344f;
|
||||
--palette-filter-border: #3d547d;
|
||||
--muted-text: #9db0c3;
|
||||
--hint-text: #7f93a8;
|
||||
--label-text: #dbe7ff;
|
||||
--palette-hint-text: #b8cff8;
|
||||
--chain-handle-text: #9fb4da;
|
||||
--viewer-bg: #0b1118;
|
||||
--dialog-backdrop: rgba(0, 0, 0, 0.55);
|
||||
--dialog-hint-text: #aebfde;
|
||||
--dialog-label-text: #c7d6f3;
|
||||
--warning-text: #ffb4c0;
|
||||
--modebar-icon: #8a96a3;
|
||||
--modebar-icon-active: #447adb;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--spacing-sm: 6px;
|
||||
--spacing-md: 8px;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--panel-bg: #eef2f7;
|
||||
--card-bg: #ffffff;
|
||||
--card-border: #c8d3e0;
|
||||
--header-bg: #ffffff;
|
||||
--header-border: #d5dee8;
|
||||
--health-bg: #ecfdf5;
|
||||
--health-text: #047857;
|
||||
--chain-selected-border: #3b82f6;
|
||||
--chain-idle-border: #cbd5e1;
|
||||
--chain-enabled-bg: #f8fafc;
|
||||
--chain-disabled-bg: #f1f5f9;
|
||||
--control-bg: #ffffff;
|
||||
--control-border: #b8c5d6;
|
||||
--control-text: #1e293b;
|
||||
--button-bg: #e8eef5;
|
||||
--button-border: #b8c5d6;
|
||||
--button-text: #1e293b;
|
||||
--primary-button-bg: #f59e0b;
|
||||
--primary-button-text: #1e293b;
|
||||
--summary-primary: #1e293b;
|
||||
--summary-secondary: #475569;
|
||||
--summary-recommendation: #2563eb;
|
||||
--palette-filter-bg: #eef2f7;
|
||||
--palette-filter-border: #c8d3e0;
|
||||
--muted-text: #64748b;
|
||||
--hint-text: #64748b;
|
||||
--label-text: #334155;
|
||||
--palette-hint-text: #64748b;
|
||||
--chain-handle-text: #64748b;
|
||||
--viewer-bg: #e2e8f0;
|
||||
--dialog-backdrop: rgba(15, 23, 42, 0.35);
|
||||
--dialog-hint-text: #64748b;
|
||||
--dialog-label-text: #475569;
|
||||
--warning-text: #dc2626;
|
||||
--modebar-icon: #636363;
|
||||
--modebar-icon-active: #447adb;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, Segoe UI, Roboto, sans-serif;
|
||||
background: var(--panel-bg);
|
||||
color: var(--control-text);
|
||||
}
|
||||
|
||||
button, select, input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--button-border);
|
||||
background: var(--button-bg);
|
||||
color: var(--button-text);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--primary-button-bg);
|
||||
color: var(--primary-button-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
input[type="file"],
|
||||
input[type="text"],
|
||||
select {
|
||||
background: var(--control-bg);
|
||||
color: var(--control-text);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
input[type="file"],
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 580px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
min-height: calc(100vh - 52px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.content-column {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useDatasetStore } from "@/stores/dataset";
|
||||
import { usePointCloudViewer } from "@/composables/usePointCloudViewer";
|
||||
|
||||
const store = useDatasetStore();
|
||||
const viewerRef = ref(null);
|
||||
const { renderLabeledCloud } = usePointCloudViewer(viewerRef);
|
||||
|
||||
const statsRows = computed(() => {
|
||||
const s = store.stats;
|
||||
if (!s) return [];
|
||||
return [
|
||||
["Всего сцен", s.total],
|
||||
["С объектом", s.withObject],
|
||||
["Без объекта", s.withoutObject],
|
||||
["Почти скрыт", s.nearly_hidden || 0],
|
||||
["Частично видим", s.partial || 0],
|
||||
["Хорошо различим", s.visible || 0],
|
||||
["Отсутствует", s.absent || 0],
|
||||
];
|
||||
});
|
||||
|
||||
const viewerCaption = computed(() => {
|
||||
if (!store.previewStem) return "";
|
||||
const meta = store.previewMeta;
|
||||
const parts = [`Превью: ${store.previewStem}`];
|
||||
if (meta?.visibility) parts.push(meta.visibility);
|
||||
if (meta?.pointCount != null) parts.push(`${meta.pointCount} pts`);
|
||||
if (store.highlightClass !== null && store.highlightClass !== undefined) {
|
||||
const opt = store.classOptions.find((c) => c.id === store.highlightClass);
|
||||
parts.push(`класс ${store.highlightClass}${opt ? ` (${opt.label})` : ""}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
});
|
||||
|
||||
function refreshViewer({ fit = true } = {}) {
|
||||
renderLabeledCloud(store.previewPoints || [], {
|
||||
highlightClass: store.highlightClass,
|
||||
fit,
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.previewPoints,
|
||||
() => {
|
||||
refreshViewer({ fit: true });
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => store.highlightClass,
|
||||
() => {
|
||||
if (store.previewPoints?.length) {
|
||||
refreshViewer({ fit: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (store.previewPoints?.length) {
|
||||
refreshViewer({ fit: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function onGenerate() {
|
||||
try {
|
||||
await store.generate();
|
||||
} catch {
|
||||
/* status already set */
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectScene(stem) {
|
||||
try {
|
||||
await store.selectScene(stem);
|
||||
} catch {
|
||||
/* status already set */
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectChange(event) {
|
||||
const stem = event.target?.value;
|
||||
if (stem) await onSelectScene(stem);
|
||||
}
|
||||
|
||||
function onModelFileChange(event) {
|
||||
const file = event.target?.files?.[0] || null;
|
||||
store.setModelFile(file);
|
||||
}
|
||||
|
||||
function onHighlightClassChange(event) {
|
||||
store.setHighlightClass(event.target?.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="layout dataset-layout">
|
||||
<aside class="sidebar dataset-sidebar">
|
||||
<section class="panel">
|
||||
<h2>Генератор датасета</h2>
|
||||
<p class="hint">
|
||||
Синтетические сцены эхолота для PointNet (сегментация: фон / object).
|
||||
Целевой объект — вершины выбранной .obj модели (класс 1).
|
||||
Файлы: <code>Area_X_scene_XXXX.npy</code> + <code>.obj</code>.
|
||||
</p>
|
||||
|
||||
<label 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">
|
||||
<span>Относительный масштаб объекта</span>
|
||||
<input
|
||||
v-model.number="store.objectScale"
|
||||
type="number"
|
||||
min="0.01"
|
||||
max="100"
|
||||
step="0.05"
|
||||
:disabled="store.busy"
|
||||
/>
|
||||
<span class="ref-caption">1.0 = размер после нормализации mesh; >1 увеличивает объект</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Кол-во лучей</span>
|
||||
<input
|
||||
v-model.number="store.beamCount"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1024"
|
||||
step="1"
|
||||
:disabled="store.busy"
|
||||
/>
|
||||
<span class="ref-caption">
|
||||
Ширина рельефа (X): N лучей = N точек по ширине сетки дна.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Длина</span>
|
||||
<input
|
||||
v-model.number="store.lengthCount"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1024"
|
||||
step="1"
|
||||
:disabled="store.busy"
|
||||
/>
|
||||
<span class="ref-caption">
|
||||
Длина рельефа (Y): L = число точек по длине сетки дна.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Число сцен</span>
|
||||
<input v-model.number="store.count" type="number" min="1" max="5000" step="1" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Seed</span>
|
||||
<input v-model.number="store.seed" type="number" min="0" step="1" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Каталог</span>
|
||||
<input v-model="store.outputDir" type="text" />
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
:disabled="!store.canGenerate"
|
||||
@click="onGenerate"
|
||||
>
|
||||
{{ store.busy ? "Генерация…" : "Сгенерировать" }}
|
||||
</button>
|
||||
|
||||
<p class="status">{{ store.statusText }}</p>
|
||||
|
||||
<div v-if="statsRows.length" class="stats">
|
||||
<h3>Статистика</h3>
|
||||
<ul>
|
||||
<li v-for="([label, value]) in statsRows" :key="label">
|
||||
<span>{{ label }}</span>
|
||||
<strong>{{ value }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="store.written.length" class="scene-picker">
|
||||
<h3>Сцена для превью</h3>
|
||||
<label class="field">
|
||||
<span>Выбор сцены</span>
|
||||
<select
|
||||
:value="store.selectedStem || ''"
|
||||
:disabled="store.previewBusy || store.busy"
|
||||
@change="onSelectChange"
|
||||
>
|
||||
<option
|
||||
v-for="item in store.written"
|
||||
:key="item.stem"
|
||||
:value="item.stem"
|
||||
>
|
||||
{{ item.stem }}
|
||||
— {{ item.visibility }}
|
||||
({{ item.pointCount }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Подсветка класса</span>
|
||||
<select
|
||||
:value="store.highlightClass === null ? 'all' : String(store.highlightClass)"
|
||||
:disabled="!store.previewPoints.length || store.previewBusy"
|
||||
@change="onHighlightClassChange"
|
||||
>
|
||||
<option value="all">Все классы</option>
|
||||
<option
|
||||
v-for="opt in store.classOptions"
|
||||
:key="opt.id"
|
||||
:value="String(opt.id)"
|
||||
>
|
||||
{{ opt.id }} — {{ opt.label }} ({{ opt.count }})
|
||||
</option>
|
||||
</select>
|
||||
<span class="ref-caption class-legend">
|
||||
<span class="swatch bg" /> background (0)
|
||||
<span class="swatch obj" /> object (1)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<ul class="file-list">
|
||||
<li
|
||||
v-for="item in store.written"
|
||||
:key="item.stem"
|
||||
:class="{ active: item.stem === store.selectedStem }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="scene-btn"
|
||||
:disabled="store.previewBusy || store.busy"
|
||||
@click="onSelectScene(item.stem)"
|
||||
>
|
||||
<code>{{ item.stem }}</code>
|
||||
<span class="meta">
|
||||
{{ item.visibility }}
|
||||
· {{ item.pointCount }} pts
|
||||
<template v-if="item.hasObject">
|
||||
· object {{ item.objectPointCount }}
|
||||
</template>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div class="content-column dataset-content">
|
||||
<div class="viewer-wrap">
|
||||
<div ref="viewerRef" class="viewer-canvas" />
|
||||
<div v-if="viewerCaption" class="viewer-label">
|
||||
{{ viewerCaption }}
|
||||
<span v-if="store.previewBusy"> · загрузка…</span>
|
||||
</div>
|
||||
</div>
|
||||
<section class="log-panel">
|
||||
<h3>Лог</h3>
|
||||
<pre class="log">{{ store.logLines.join("\n") || "—" }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dataset-layout {
|
||||
align-items: stretch;
|
||||
}
|
||||
.dataset-sidebar {
|
||||
width: 320px;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.panel {
|
||||
padding: 14px 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.class-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.swatch.bg {
|
||||
background: #64748b;
|
||||
}
|
||||
.swatch.obj {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.field input,
|
||||
.field select {
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--header-border);
|
||||
background: var(--button-bg);
|
||||
color: var(--control-text);
|
||||
}
|
||||
.primary {
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.status {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
.stats h3,
|
||||
.scene-picker h3,
|
||||
.log-panel h3 {
|
||||
margin: 8px 0 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stats ul,
|
||||
.file-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.stats li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.scene-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.file-list {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.file-list li {
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
}
|
||||
.file-list li.active .scene-btn {
|
||||
background: var(--chain-enabled-bg);
|
||||
border-color: var(--chain-selected-border);
|
||||
}
|
||||
.scene-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
margin: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.scene-btn:hover:not(:disabled) {
|
||||
background: var(--button-bg);
|
||||
}
|
||||
.scene-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.file-list .meta {
|
||||
color: var(--muted-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
.dataset-content {
|
||||
min-height: 0;
|
||||
height: calc(100vh - 76px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 12px 0;
|
||||
}
|
||||
.viewer-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
overflow: hidden;
|
||||
background: #0b1118;
|
||||
}
|
||||
.viewer-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.log-panel {
|
||||
flex: 0 0 140px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.log {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
white-space: pre-wrap;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
import { useGeneratorStore } from "@/stores/generator";
|
||||
import GeneratorSidebar from "@/components/generator/GeneratorSidebar.vue";
|
||||
import GeneratorViewer from "@/components/generator/GeneratorViewer.vue";
|
||||
|
||||
const store = useGeneratorStore();
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.bootstrap();
|
||||
} catch (error) {
|
||||
store.statusText = `Ошибка каталога: ${error.message}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="layout generator-layout">
|
||||
<aside class="sidebar">
|
||||
<GeneratorSidebar />
|
||||
</aside>
|
||||
<div class="content-column generator-content">
|
||||
<GeneratorViewer />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.generator-layout {
|
||||
align-items: stretch;
|
||||
}
|
||||
.generator-content {
|
||||
min-height: 0;
|
||||
height: calc(100vh - 76px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
import { usePipelineStore } from "@/stores/pipeline";
|
||||
import PipelineDashboard from "@/components/PipelineDashboard.vue";
|
||||
import ViewerPanel from "@/components/viewer/ViewerPanel.vue";
|
||||
import MetricsStrip from "@/components/pipeline/MetricsStrip.vue";
|
||||
import WizardPanel from "@/components/WizardPanel.vue";
|
||||
|
||||
const store = usePipelineStore();
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.stageCards.length) {
|
||||
try {
|
||||
await store.bootstrap();
|
||||
} catch {
|
||||
// health banner in App.vue already surfaces API errors
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="layout">
|
||||
<aside class="sidebar">
|
||||
<PipelineDashboard />
|
||||
<WizardPanel />
|
||||
</aside>
|
||||
<div class="content-column">
|
||||
<ViewerPanel />
|
||||
<MetricsStrip />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
Reference in New Issue
Block a user