diff --git a/frontend/dcViewer.css b/frontend/dcViewer.css index b5f8709..3320b54 100644 --- a/frontend/dcViewer.css +++ b/frontend/dcViewer.css @@ -1183,6 +1183,55 @@ header { min-width: 0; } +.inspector-version-heading { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.inspector-version-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; +} + +.inspector-icon-btn { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border: 0; + border-radius: 12px; + background: var(--nodedc-glass-control-bg); + box-shadow: var(--nodedc-glass-control-shadow); + color: rgba(245, 245, 250, 0.72); + cursor: pointer; + transition: background 140ms ease, color 140ms ease, opacity 140ms ease; +} + +.inspector-icon-btn:hover { + color: rgba(245, 245, 250, 0.94); + background: var(--nodedc-glass-control-hover); +} + +.inspector-icon-btn:disabled { + cursor: default; + opacity: 0.42; +} + +.inspector-icon-btn svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 1.55; + stroke-linecap: round; + stroke-linejoin: round; +} + .inspector-color-host { min-width: 0; } @@ -3174,10 +3223,174 @@ body[data-viewer-mode="guest"] .tb-btn:not([data-action="measure"]):not([data-ac body[data-viewer-mode="guest"] #settingsPanel, body[data-viewer-mode="guest"] #templatesPanel, body[data-viewer-mode="guest"] #projectNameSection, -body[data-viewer-mode="guest"] #saveProjectSection { +body[data-viewer-mode="guest"] #saveProjectSection, +body[data-viewer-mode="guest"] #modelVersionUploadButton { display: none !important; } +.version-history-backdrop { + position: fixed; + inset: 0; + z-index: var(--z-modal, 10000); + background: rgba(0, 0, 0, 0.58); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +.version-history-modal { + position: fixed; + left: 50%; + top: 50%; + z-index: calc(var(--z-modal, 10000) + 1); + display: flex; + width: min(96vw, 1280px); + max-height: min(76vh, 720px); + min-height: 220px; + flex-direction: column; + overflow: hidden; + border-radius: 22px; + background: rgba(20, 20, 22, 0.96); + box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55); + transform: translate(-50%, -50%); +} + +.version-history-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 20px 14px; +} + +.version-history-title { + min-width: 0; +} + +.version-history-title strong { + display: block; + overflow: hidden; + color: rgba(245, 245, 250, 0.94); + font-size: 15px; + font-weight: 760; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.version-history-title span { + display: block; + overflow: hidden; + margin-top: 6px; + color: rgba(245, 245, 250, 0.55); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.version-history-close { + flex: 0 0 auto; +} + +.version-history-body { + min-height: 0; + flex: 1 1 auto; + overflow: auto; + padding: 0 16px 16px; +} + +.version-history-table { + min-width: 980px; + overflow: hidden; + border-radius: 16px; + background: rgba(255, 255, 255, 0.035); +} + +.version-history-row { + display: grid; + grid-template-columns: minmax(260px, 1.6fr) 80px 96px 140px 140px 150px 120px; + align-items: center; + gap: 12px; + min-height: 56px; + padding: 10px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.065); + color: rgba(245, 245, 250, 0.72); + font-size: 12px; +} + +.version-history-row:last-child { + border-bottom: 0; +} + +.version-history-row--head { + min-height: 34px; + color: rgba(245, 245, 250, 0.52); + font-size: 11px; + font-weight: 760; + text-transform: uppercase; +} + +.version-history-file, +.version-history-cell { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.version-history-file strong { + display: block; + overflow: hidden; + color: rgba(245, 245, 250, 0.82); + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + +.version-history-file span { + display: block; + overflow: hidden; + margin-top: 5px; + color: rgba(245, 245, 250, 0.42); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.version-history-current { + display: block; + margin-top: 4px; + color: var(--modal-btn-primary, var(--accent)); + font-size: 10px; + font-weight: 760; +} + +.version-history-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.version-history-empty { + border-radius: 16px; + background: rgba(255, 255, 255, 0.035); + padding: 34px 16px; + color: rgba(245, 245, 250, 0.56); + font-size: 13px; + text-align: center; +} + +@media (max-width: 720px) { + .version-history-modal { + width: min(94vw, 560px); + max-height: 78vh; + } + + .version-history-body { + padding: 0 10px 12px; + } +} + .section-toolbar { position: fixed; left: var(--section-toolbar-left); diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js index 0d97570..8d6a1a6 100644 --- a/frontend/dcViewer.js +++ b/frontend/dcViewer.js @@ -102,6 +102,11 @@ const customDisplayColorControlHost = document.getElementById("customDisplayColo const customDisplaySaturationControl = document.getElementById("customDisplaySaturationControl"); const lightingModeControl = document.getElementById("lightingModeControl"); const lightingModeSelectHost = document.getElementById("lightingModeSelect"); +const modelVersionControl = document.getElementById("modelVersionControl"); +const modelVersionSelectHost = document.getElementById("modelVersionSelect"); +const modelVersionHistoryButton = document.getElementById("modelVersionHistoryButton"); +const modelVersionUploadButton = document.getElementById("modelVersionUploadButton"); +const modelVersionFileInput = document.getElementById("modelVersionFileInput"); const navigationAxisControl = document.getElementById("navigationAxisControl"); const navigationAxisSelectHost = document.getElementById("navigationAxisSelect"); const zoomSpeedControl = document.getElementById("zoomSpeedControl"); @@ -178,6 +183,7 @@ const MODEL_SETTINGS_API = "/api/model-settings"; const AUTH_SESSION_API = "/api/auth/session"; const SHARES_API = "/api/shares"; const COMMENTS_API = "/api/comments"; +const UPLOAD_VERSIONS_API = "/api/uploads/versions"; const acceptByType = { xkt: ".xkt", @@ -286,6 +292,10 @@ const activeModels = []; let viewerMode = "standard"; let runtimeSessionInfo = { authRequired: false, authenticated: false, canShare: true, user: null }; let startupSharePayload = null; +let modelVersionHistory = null; +let modelVersionHistoryLoading = false; +let modelVersionUploading = false; +let versionHistoryModal = null; const GUEST_TOOLBAR_ACTIONS = new Set(["measure", "section", "projection"]); const memoryStore = new Map(); // url -> ArrayBuffer const blobUrls = []; @@ -305,6 +315,7 @@ let displayModeSelect = null; let customDisplayColorControl = null; let customDisplaySaturationSlider = null; let lightingModeSelect = null; +let modelVersionSelect = null; let navigationAxisSelect = null; let zoomSpeedSlider = null; let defaultCameraWorldAxis = null; @@ -1795,13 +1806,16 @@ const getShareableModelPayload = () => { if (!src) { return null; } + const versionIdentity = getModelVersionIdentity(entry); return { assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || null, + projectId: entry.meta?.projectId || entry.meta?.conversion?.projectId || versionIdentity.projectId || null, name: entry.name || entry.meta?.label || src.split("/").pop() || "model", settingsSrc: settingsSrc || src, src, type: entry.type || normalizeType(null, src), versionId: entry.meta?.versionId || entry.meta?.conversion?.versionId || null, + versions: Array.isArray(entry.meta?.versions) ? entry.meta.versions : [], }; }; @@ -3015,6 +3029,7 @@ const updatePanelsVisibility = () => { } updateSectionControls(); updateCommentsVisibility(); + updateModelVersionControl(); applyRuntimeMode(); }; @@ -4181,12 +4196,13 @@ const fetchJSONWithTimeout = async (url, options = {}, timeoutMs = 5000) => { } }; -const uploadBuffer = async (bytes, filename) => { +const uploadBuffer = async (bytes, filename, options = {}) => { const safeName = filename || "upload.bin"; const params = new URLSearchParams({ filename: safeName }); - if (currentProjectMeta?.id) { - params.set("projectId", currentProjectMeta.id); - } + const projectId = options.projectId || currentProjectMeta?.id; + if (projectId) params.set("projectId", projectId); + if (options.assetId) params.set("assetId", options.assetId); + if (options.version) params.set("version", String(options.version)); const res = await fetch(`/api/uploads?${params.toString()}`, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, @@ -4205,7 +4221,423 @@ const uploadBuffer = async (bytes, filename) => { if (!data || !data.src) { throw new Error("Upload response is missing src"); } - return data.src; + return data; +}; + +const getVersionKey = (version) => version?.versionId || `version-${version?.version || "unknown"}`; + +const getModelVersionIdentity = (entry = getPrimaryModelEntry()) => { + const meta = entry?.meta || {}; + const direct = { + projectId: meta.projectId || meta.conversion?.projectId || null, + assetId: meta.assetId || meta.conversion?.assetId || null, + }; + if (direct.projectId && direct.assetId) return direct; + const candidates = [ + meta.sourceSrc, + meta.settingsSrc, + meta.artifactSrc, + meta.conversion?.sourceSrc, + meta.conversion?.artifactSrc, + entry?.src, + ]; + for (const candidate of candidates) { + const identity = extractUploadIdentityFromSrc(candidate); + if (identity.projectId && identity.assetId) return identity; + } + return direct; +}; + +const normalizeVersionSource = (value) => { + if (!value || typeof value !== "string") return ""; + const normalized = value.trim(); + if (normalized.startsWith("uploads/")) return `/${normalized}`; + try { + return new URL(normalized, window.location.href).toString(); + } catch (_) { + return normalized; + } +}; + +const getVersionViewerSrc = (version) => { + if (!version?.previewAvailable) return ""; + return normalizeVersionSource(version.src || version.viewerUrl || version.downloadUrl || ""); +}; + +const getVersionSettingsSrc = (version) => ( + version?.sourceSrc || + version?.conversion?.sourceSrc || + version?.downloadUrl || + version?.src || + "" +); + +const getVersionViewerType = (version) => { + const src = getVersionViewerSrc(version); + return normalizeType(version?.type, src) || normalizeType(guessTypeFromName(src || version?.originalFilename || "")); +}; + +const getCurrentVersionIdForEntry = (entry = getPrimaryModelEntry()) => { + const meta = entry?.meta || {}; + return meta.versionId || meta.conversion?.versionId || modelVersionHistory?.currentVersionId || null; +}; + +const isCurrentVersionRecord = (version, entry = getPrimaryModelEntry()) => { + const currentVersionId = getCurrentVersionIdForEntry(entry); + if (currentVersionId && version?.versionId) return currentVersionId === version.versionId; + const currentVersion = entry?.meta?.version || modelVersionHistory?.currentVersion; + return Number(currentVersion) === Number(version?.version); +}; + +const formatVersionOptionLabel = (version) => { + const number = Number(version?.version); + const prefix = Number.isFinite(number) ? `v${number}` : "Версия"; + const name = version?.originalFilename || "model"; + return `${prefix} · ${name}`; +}; + +const buildVersionHistoryParams = (entry = getPrimaryModelEntry()) => { + const identity = getModelVersionIdentity(entry); + const params = new URLSearchParams(); + if (identity.projectId && identity.assetId) { + params.set("projectId", identity.projectId); + params.set("assetId", identity.assetId); + return params; + } + const src = getModelSettingsSrc(entry) || entry?.meta?.sourceSrc || entry?.meta?.artifactSrc || entry?.src || ""; + if (src) params.set("src", src); + return params; +}; + +const setEntryVersionHistory = (entry, history) => { + if (!entry || !history) return; + const versions = Array.isArray(history.versions) ? history.versions : []; + const meta = entry.meta || {}; + entry.meta = { + ...meta, + assetId: history.assetId || meta.assetId || null, + projectId: history.projectId || meta.projectId || null, + versionId: meta.versionId || history.currentVersionId || null, + version: meta.version || history.currentVersion || null, + versions, + }; +}; + +const fetchVersionHistoryForEntry = async (entry = getPrimaryModelEntry()) => { + const params = buildVersionHistoryParams(entry); + if (!params.toString()) return null; + return fetchJSON(`${UPLOAD_VERSIONS_API}?${params.toString()}`); +}; + +const updateModelVersionControl = () => { + const entry = getPrimaryModelEntry(); + const versions = modelVersionHistory?.versions || entry?.meta?.versions || []; + const identity = getModelVersionIdentity(entry); + const hasControl = !!entry && (!!identity.assetId || versions.length > 0); + modelVersionControl?.classList.toggle("hidden", !hasControl); + if (!hasControl) return; + + const currentVersionId = getCurrentVersionIdForEntry(entry); + const currentRecord = versions.find((version) => currentVersionId && version?.versionId === currentVersionId) || + versions.find((version) => Number(version?.version) === Number(entry?.meta?.version)) || + versions[versions.length - 1]; + const currentValue = currentRecord ? getVersionKey(currentRecord) : + currentVersionId || + ""; + const options = versions.length + ? versions.map((version) => ({ value: getVersionKey(version), label: formatVersionOptionLabel(version) })) + : [{ value: "loading", label: modelVersionHistoryLoading ? "Загружаем версии..." : "Версии не найдены" }]; + + if (modelVersionSelect) { + modelVersionSelect.setOptions(options, currentValue || options[0]?.value); + } + if (modelVersionHistoryButton) { + modelVersionHistoryButton.disabled = modelVersionHistoryLoading || !versions.length; + } + if (modelVersionUploadButton) { + modelVersionUploadButton.disabled = isGuestMode() || modelVersionUploading || !identity.assetId || !identity.projectId; + modelVersionUploadButton.title = modelVersionUploading ? "Загружаем версию..." : "Добавить версию"; + } +}; + +const refreshModelVersionHistory = async ({ silent = false } = {}) => { + const entry = getPrimaryModelEntry(); + if (!entry) { + modelVersionHistory = null; + updateModelVersionControl(); + return null; + } + const params = buildVersionHistoryParams(entry); + if (!params.toString()) { + updateModelVersionControl(); + return null; + } + modelVersionHistoryLoading = true; + updateModelVersionControl(); + try { + const history = await fetchVersionHistoryForEntry(entry); + modelVersionHistory = history; + setEntryVersionHistory(entry, history); + updateModelVersionControl(); + return history; + } catch (err) { + if (!silent) console.warn("model version history unavailable", err); + updateModelVersionControl(); + return null; + } finally { + modelVersionHistoryLoading = false; + updateModelVersionControl(); + } +}; + +const openModelVersion = async (version) => { + const viewerSrc = getVersionViewerSrc(version); + const viewerType = getVersionViewerType(version); + if (!viewerSrc || !viewerType) { + setStatus("Эта версия еще не готова к просмотру"); + return; + } + const previousEntry = getPrimaryModelEntry(); + const identity = getModelVersionIdentity(previousEntry); + const settingsSrc = getVersionSettingsSrc(version); + const meta = { + ...(previousEntry?.meta || {}), + assetId: version.assetId || identity.assetId || previousEntry?.meta?.assetId || null, + projectId: version.projectId || identity.projectId || previousEntry?.meta?.projectId || null, + version: version.version || null, + versionId: version.versionId || null, + versions: modelVersionHistory?.versions || previousEntry?.meta?.versions || [], + label: version.originalFilename || previousEntry?.name || "model", + sourceSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc, + settingsSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc, + artifactSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc, + viewerSettings: null, + }; + const settings = await fetchModelSettings(settingsSrc).catch(() => null); + if (settings?.viewerSettings) { + meta.viewerSettings = settings.viewerSettings; + } + await loadModelAsync({ + type: viewerType, + url: viewerSrc, + name: version.originalFilename || previousEntry?.name || "model", + replace: true, + id: previousEntry?.model?.id || "model", + meta, + }); + modelVersionHistory = { + ...(modelVersionHistory || {}), + currentVersion: version.version || modelVersionHistory?.currentVersion || null, + currentVersionId: version.versionId || modelVersionHistory?.currentVersionId || null, + versions: modelVersionHistory?.versions || meta.versions || [], + }; + updateModelVersionControl(); +}; + +const uploadModelVersion = async (file) => { + const entry = getPrimaryModelEntry(); + const identity = getModelVersionIdentity(entry); + if (!file || !entry || !identity.projectId || !identity.assetId || isGuestMode()) return; + const detected = guessTypeFromName(file.name); + const uploadOnly = /\.stp?$/i.test(file.name); + if (!detected && !uploadOnly) { + showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl, .step или .stp"); + return; + } + const versions = modelVersionHistory?.versions || entry.meta?.versions || []; + const nextVersion = Math.max(0, ...versions.map((version) => Number(version?.version) || 0), Number(entry.meta?.version) || 0) + 1; + modelVersionUploading = true; + updateModelVersionControl(); + try { + const bytes = cloneToUint8(await file.arrayBuffer()) || new Uint8Array(0); + const uploaded = await uploadBuffer(bytes, file.name, { + projectId: identity.projectId, + assetId: identity.assetId, + version: nextVersion, + }); + uploadCache.set(entry.src || file.name, uploaded.src); + const history = await refreshModelVersionHistory({ silent: true }); + const newVersion = history?.versions?.find((version) => version.versionId === uploaded.versionId) || + history?.versions?.find((version) => Number(version.version) === Number(uploaded.version)); + if (newVersion?.previewAvailable) { + await openModelVersion(newVersion); + } else { + setStatus(`Версия v${uploaded.version || nextVersion} загружена. Просмотр появится после подготовки модели.`); + } + } catch (err) { + console.error(err); + showError(err.message || "Не удалось загрузить версию модели"); + } finally { + modelVersionUploading = false; + updateModelVersionControl(); + } +}; + +const closeVersionHistoryModal = () => { + versionHistoryModal?.backdrop?.remove(); + versionHistoryModal?.modal?.remove(); + versionHistoryModal = null; +}; + +const renderVersionHistoryModal = () => { + if (!versionHistoryModal) return; + const entry = getPrimaryModelEntry(); + const versions = modelVersionHistory?.versions || entry?.meta?.versions || []; + const { body, title, subtitle } = versionHistoryModal; + title.textContent = "История версий"; + subtitle.textContent = entry?.name || entry?.meta?.label || modelVersionHistory?.originalFilename || "model"; + body.replaceChildren(); + if (!versions.length) { + const empty = document.createElement("div"); + empty.className = "version-history-empty"; + empty.textContent = modelVersionHistoryLoading ? "Загружаем версии..." : "История версий пока не создана."; + body.appendChild(empty); + return; + } + const table = document.createElement("div"); + table.className = "version-history-table"; + const header = document.createElement("div"); + header.className = "version-history-row version-history-row--head"; + ["Файл", "Версия", "Размер", "Автор", "Дата", "Статус", "Действия"].forEach((label) => { + const cell = document.createElement("div"); + cell.textContent = label; + if (label === "Действия") cell.style.textAlign = "right"; + header.appendChild(cell); + }); + table.appendChild(header); + [...versions].sort((a, b) => Number(b.version || 0) - Number(a.version || 0)).forEach((version) => { + const row = document.createElement("div"); + row.className = "version-history-row"; + const fileCell = document.createElement("div"); + fileCell.className = "version-history-file"; + const fileName = document.createElement("strong"); + fileName.textContent = version.originalFilename || "model"; + fileCell.appendChild(fileName); + if (version.sha256) { + const sha = document.createElement("span"); + sha.textContent = `sha256 ${version.sha256}`; + fileCell.appendChild(sha); + } + row.appendChild(fileCell); + + const versionCell = document.createElement("div"); + versionCell.className = "version-history-cell"; + versionCell.textContent = Number.isFinite(Number(version.version)) ? `v${version.version}` : "—"; + if (isCurrentVersionRecord(version, entry)) { + const current = document.createElement("span"); + current.className = "version-history-current"; + current.textContent = "текущая"; + versionCell.appendChild(current); + } + row.appendChild(versionCell); + + const sizeCell = document.createElement("div"); + sizeCell.className = "version-history-cell"; + sizeCell.textContent = formatFileSize(version.size) || "—"; + row.appendChild(sizeCell); + + const authorCell = document.createElement("div"); + authorCell.className = "version-history-cell"; + authorCell.textContent = version.uploadedBy || "—"; + row.appendChild(authorCell); + + const dateCell = document.createElement("div"); + dateCell.className = "version-history-cell"; + dateCell.textContent = formatDateTime(version.uploadedAt); + row.appendChild(dateCell); + + const statusCell = document.createElement("div"); + statusCell.className = "version-history-cell"; + statusCell.textContent = version.status === "ready" + ? "Модель готова" + : version.status === "failed" + ? "Ошибка подготовки" + : "Ожидает подготовки"; + row.appendChild(statusCell); + + const actions = document.createElement("div"); + actions.className = "version-history-actions"; + const viewButton = document.createElement("button"); + viewButton.type = "button"; + viewButton.className = "inspector-icon-btn"; + viewButton.title = "Посмотреть"; + viewButton.innerHTML = ``; + viewButton.disabled = !getVersionViewerSrc(version); + viewButton.addEventListener("click", () => { + closeVersionHistoryModal(); + openModelVersion(version); + }); + actions.appendChild(viewButton); + if (version.downloadUrl) { + const download = document.createElement("a"); + download.className = "inspector-icon-btn"; + download.href = version.downloadUrl; + download.target = "_blank"; + download.rel = "noopener noreferrer"; + download.download = version.originalFilename || "model"; + download.title = "Скачать"; + download.innerHTML = ``; + actions.appendChild(download); + } + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "inspector-icon-btn"; + deleteButton.title = "Удалить версию"; + deleteButton.innerHTML = ``; + deleteButton.disabled = isGuestMode() || isCurrentVersionRecord(version, entry); + deleteButton.addEventListener("click", async () => { + deleteButton.disabled = true; + try { + await fetchJSON("/api/uploads/version", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(version), + }); + await refreshModelVersionHistory({ silent: true }); + renderVersionHistoryModal(); + } catch (err) { + showError(err.message || "Не удалось удалить версию"); + } + }); + actions.appendChild(deleteButton); + row.appendChild(actions); + table.appendChild(row); + }); + body.appendChild(table); +}; + +const openVersionHistoryModal = async () => { + if (versionHistoryModal) { + renderVersionHistoryModal(); + return; + } + const backdrop = document.createElement("div"); + backdrop.className = "version-history-backdrop"; + const modal = document.createElement("div"); + modal.className = "version-history-modal"; + const header = document.createElement("div"); + header.className = "version-history-header"; + const titleWrap = document.createElement("div"); + titleWrap.className = "version-history-title"; + const title = document.createElement("strong"); + const subtitle = document.createElement("span"); + titleWrap.append(title, subtitle); + const close = document.createElement("button"); + close.type = "button"; + close.className = "inspector-icon-btn version-history-close"; + close.title = "Закрыть"; + close.innerHTML = ``; + header.append(titleWrap, close); + const body = document.createElement("div"); + body.className = "version-history-body"; + modal.append(header, body); + document.body.append(backdrop, modal); + versionHistoryModal = { backdrop, modal, title, subtitle, body }; + close.addEventListener("click", closeVersionHistoryModal); + backdrop.addEventListener("click", closeVersionHistoryModal); + renderVersionHistoryModal(); + await refreshModelVersionHistory({ silent: true }); + renderVersionHistoryModal(); }; const loadProjectsList = async () => { @@ -4353,11 +4785,17 @@ const buildProjectSnapshot = async (name) => { const modelType = normalizeType(entry.type, entry.src) || normalizeType(guessTypeFromName(entry.src)) || entry.type || null; const src = typeof entry.src === "string" ? entry.src : ""; const meta = entry.meta || {}; + const versionIdentity = getModelVersionIdentity(entry); const baseModel = { id: model.id, type: modelType, meta: { label: meta.label || entry.name || model.id, + assetId: meta.assetId || meta.conversion?.assetId || versionIdentity.assetId || null, + projectId: meta.projectId || meta.conversion?.projectId || versionIdentity.projectId || null, + version: meta.version || null, + versionId: meta.versionId || meta.conversion?.versionId || null, + versions: Array.isArray(meta.versions) ? meta.versions : [], metadataPath: meta.metadataPath || null, settingsSrc: getModelSettingsSrc(entry) || null, sourceSrc: meta.sourceSrc || meta.conversion?.sourceSrc || meta.manifest?.sourceSrc || null, @@ -4387,11 +4825,21 @@ const buildProjectSnapshot = async (name) => { } const bytes = cloneToUint8(buffer); const filename = (entry.name && entry.name.includes(".")) ? entry.name : `${model.id || "model"}.${modelType || "bin"}`; - const uploadedSrc = await uploadBuffer(bytes, filename); + const uploaded = await uploadBuffer(bytes, filename); + const uploadedSrc = uploaded.src; uploadCache.set(cacheKey, uploadedSrc); models.push({ ...baseModel, - src: uploadedSrc + src: uploadedSrc, + meta: { + ...baseModel.meta, + assetId: uploaded.assetId || baseModel.meta.assetId || null, + projectId: uploaded.projectId || baseModel.meta.projectId || null, + version: uploaded.version || baseModel.meta.version || null, + versionId: uploaded.versionId || baseModel.meta.versionId || null, + sourceSrc: uploaded.src || baseModel.meta.sourceSrc || null, + settingsSrc: uploaded.src || baseModel.meta.settingsSrc || null, + } }); } @@ -5262,6 +5710,7 @@ const initViewer = async () => { }); updateNavCubeVisibility(); updatePanelsVisibility(); + refreshModelVersionHistory({ silent: true }).catch(() => {}); // если есть сохранённые трансформы для модели — применим applyStoredTransformsToModel(id); @@ -5354,7 +5803,9 @@ const initViewer = async () => { settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src), artifactSrc: normalizeModelSettingsSrc(src), assetId: sharedViewer?.assetId || null, + projectId: sharedViewer?.projectId || null, versionId: sharedViewer?.versionId || null, + versions: Array.isArray(sharedViewer?.versions) ? sharedViewer.versions : [], shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(), viewerSettings: null, }; @@ -5736,6 +6187,20 @@ const loadProjectSnapshot = async (project) => { applyHighlightTheme(); applyMeasureTheme(); applySectionPlaneTheme(); + if (modelVersionControl && modelVersionSelectHost) { + modelVersionSelect = createGlassSelect({ + host: modelVersionSelectHost, + value: "loading", + options: [{ value: "loading", label: "Версии не найдены" }], + ariaLabel: "Версия модели", + onChange: (value) => { + const versions = modelVersionHistory?.versions || getPrimaryModelEntry()?.meta?.versions || []; + const version = versions.find((item) => getVersionKey(item) === value); + if (version) openModelVersion(version); + }, + }); + updateModelVersionControl(); + } if (displayModeControl && displayModeSelectHost) { displayModeSelect = createGlassSelect({ host: displayModeSelectHost, @@ -5833,6 +6298,16 @@ const loadProjectSnapshot = async (project) => { setInspectorCollapsed(false); bringFloatingWindowToFront("inspector"); }); + modelVersionHistoryButton?.addEventListener("click", () => openVersionHistoryModal()); + modelVersionUploadButton?.addEventListener("click", () => { + if (isGuestMode() || modelVersionUploading) return; + modelVersionFileInput?.click(); + }); + modelVersionFileInput?.addEventListener("change", async (event) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) await uploadModelVersion(file); + }); registerFloatingWindowElement("inspector", sidePanelEl); registerFloatingWindowElement("comments", commentsPanel); registerFloatingWindowElement("settings", settingsPanel); @@ -5935,7 +6410,7 @@ const loadProjectSnapshot = async (project) => { }); } - const handleFile = (file) => { + const handleFile = async (file) => { if (isGuestMode()) { setStatus("Гостевой просмотр не принимает загрузку файлов"); return; @@ -5946,16 +6421,39 @@ const loadProjectSnapshot = async (project) => { return; } log(`Файл выбран: ${file.name} (${detected})`, false); - const urlKey = `memory:${crypto.randomUUID ? crypto.randomUUID() : Date.now()}`; - file.arrayBuffer().then((buf) => { - // Храним копию буфера, чтобы загрузчик не детачил исходный + setStatus(`Загружаем: ${file.name}...`); + setLoading(true); + try { + const buf = await file.arrayBuffer(); const bytes = cloneToUint8(buf) || new Uint8Array(buf.byteLength || 0); - memoryStore.set(urlKey, bytes); - loadModel({ type: detected, url: urlKey, name: file.name, replace: !addMode.checked }); - }).catch((err) => { + const uploaded = await uploadBuffer(bytes, file.name); + const uploadIdentity = { + projectId: uploaded.projectId || null, + assetId: uploaded.assetId || null, + }; + loadModel({ + type: detected, + url: uploaded.src, + name: file.name, + replace: !addMode.checked, + meta: { + label: file.name, + source: "upload", + settingsSrc: uploaded.src, + sourceSrc: uploaded.src, + artifactSrc: uploaded.src, + assetId: uploadIdentity.assetId, + projectId: uploadIdentity.projectId, + version: uploaded.version || null, + versionId: uploaded.versionId || null, + versions: [], + } + }); + } catch (err) { console.error(err); - showError(err.message || err); - }); + showError(err.message || "Не удалось загрузить модель"); + setLoading(false); + } }; if (chooseFileBtn) { diff --git a/frontend/index.html b/frontend/index.html index 077139b..68ddc3e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -264,6 +264,29 @@