Добавить Vue 3 web-dashboard с расширенным API и Docker-сборкой.

Полноценный браузерный UI (Pinia, Three.js) с паритетом Qt: редактор цепочки, wizard, пресеты, метрики и 3D viewer; API расширен для catalog/validate/demo/user-presets; CLI отдаёт step metrics в JSON.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-18 14:53:53 +03:00
co-authored by Cursor
parent 45b2ed6e22
commit 18a58f2e85
33 changed files with 4304 additions and 56 deletions
+71
View File
@@ -0,0 +1,71 @@
<script setup>
import { onMounted, ref } from "vue";
import { api } from "@/api/client";
import { usePipelineStore } from "@/stores/pipeline";
import { useTheme } from "@/composables/useTheme";
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();
const { theme, toggleTheme } = useTheme();
const healthText = ref("Checking API...");
onMounted(async () => {
try {
await store.bootstrap();
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">
<h1>DotsToSirface</h1>
<div class="header-actions">
<button type="button" class="theme-toggle" @click="toggleTheme">
{{ theme === "light" ? "Тёмная тема" : "Светлая тема" }}
</button>
<div class="health">{{ healthText }}</div>
</div>
</header>
<main class="layout">
<aside class="sidebar">
<PipelineDashboard />
<WizardPanel />
</aside>
<div class="content-column">
<ViewerPanel />
<MetricsStrip />
</div>
</main>
</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);
}
.header h1 { margin: 0; font-size: 20px; }
.header-actions { display: flex; gap: 10px; align-items: center; }
.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>
+54
View File
@@ -0,0 +1,54 @@
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(payload.detail || `Request failed: ${path}`);
}
return payload;
}
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();
},
};
+469
View File
@@ -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,
};
+156
View File
@@ -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,97 @@
<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" @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" :disabled="store.busy" @click="onRun">Применить</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="Snapshot name" />
<button type="button" @click="store.saveSnapshot(snapshotName)">Save snapshot</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; }
</style>
+32
View File
@@ -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,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,230 @@
<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 = "dotstosirface-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,
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, store.surfaceVisible],
() => {
renderGeometry(store.viewerPoints, store.viewerTriangles, store.surfaceVisible);
resize();
},
{ deep: true },
);
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,176 @@
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 fitCamera(points) {
if (!points?.length) return;
const box = new THREE.Box3();
for (const p of points) box.expandByPoint(new THREE.Vector3(p[0], p[1], p[2]));
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const radius = Math.max(size.x, size.y, size.z) * 0.6 || 1;
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 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 pointsGeometry = new THREE.BufferGeometry();
pointsGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
pointCloud = new THREE.Points(
pointsGeometry,
new THREE.PointsMaterial({ color: 0x7dd3fc, size: 0.01, sizeAttenuation: true }),
);
scene.add(pointCloud);
if (surfaceVisible && 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, 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,
}),
);
scene.add(meshObject);
}
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 = `dotstosirface-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,
resize,
setBackground,
resetCamera,
downloadSnapshot,
};
}
+24
View File
@@ -0,0 +1,24 @@
import { ref, watch } from "vue";
const THEME_KEY = "dotstosirface-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 };
}
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import { initTheme } from "./composables/useTheme";
import "./styles/main.css";
initTheme();
const app = createApp(App);
app.use(createPinia());
app.mount("#app");
+341
View File
@@ -0,0 +1,341 @@
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: "",
};
}
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: {},
}),
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";
},
},
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.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 || `snapshot-${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 },
},
];
},
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 = `Snapshot '${name}' loaded.`;
},
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}' сохранён.`;
},
},
});
+158
View File
@@ -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; }
}