Add BIM model version controls

This commit is contained in:
CODEX
2026-06-23 11:32:49 +03:00
parent 09492a5fdc
commit bf34f35d6c
4 changed files with 948 additions and 22 deletions
+214 -1
View File
@@ -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);
+514 -16
View File
@@ -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 = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M1.8 8s2.1-4.1 6.2-4.1S14.2 8 14.2 8 12.1 12.1 8 12.1 1.8 8 1.8 8Z"></path><circle cx="8" cy="8" r="1.7"></circle></svg>`;
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 = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2.8v7.1"></path><path d="m5.3 7.2 2.7 2.7 2.7-2.7"></path><path d="M3 12.8h10"></path></svg>`;
actions.appendChild(download);
}
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "inspector-icon-btn";
deleteButton.title = "Удалить версию";
deleteButton.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.2 4.5h9.6"></path><path d="M6.2 4.5V3.1h3.6v1.4"></path><path d="m4.4 4.5.55 8.4h6.1l.55-8.4"></path><path d="M6.65 6.5v4.3"></path><path d="M9.35 6.5v4.3"></path></svg>`;
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 = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75"></path></svg>`;
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) {
+23
View File
@@ -264,6 +264,29 @@
<div class="panel-section inspector-main-section">
<div id="treeContainer" class="tree"></div>
<div class="inspector-controls">
<div class="inspector-control-row inspector-control-row--full hidden" id="modelVersionControl">
<div class="inspector-version-heading">
<div class="inspector-control-label">Версия модели</div>
<div class="inspector-version-actions">
<button class="inspector-icon-btn" id="modelVersionHistoryButton" type="button" title="История версий" aria-label="История версий">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.2 3.4v3.3h3.3"></path>
<path d="M3.7 6.7A4.7 4.7 0 1 0 5.1 3"></path>
<path d="M8 5.1v3.2l2.1 1.2"></path>
</svg>
</button>
<button class="inspector-icon-btn" id="modelVersionUploadButton" type="button" title="Добавить версию" aria-label="Добавить версию">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 11.4V3.6"></path>
<path d="M5.1 6.4 8 3.5l2.9 2.9"></path>
<path d="M3.2 11.8v1.4h9.6v-1.4"></path>
</svg>
</button>
</div>
</div>
<div class="inspector-select-host" id="modelVersionSelect"></div>
<input id="modelVersionFileInput" class="hidden" type="file" accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl,.step,.stp">
</div>
<div class="inspector-control-row inspector-control-row--half" id="displayModeControl">
<div class="inspector-control-label">Режим отображения</div>
<div class="inspector-select-host" id="displayModeSelect"></div>