Add BIM model version controls
This commit is contained in:
parent
09492a5fdc
commit
bf34f35d6c
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
console.error(err);
|
||||
showError(err.message || 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 || "Не удалось загрузить модель");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (chooseFileBtn) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
202
server/index.js
202
server/index.js
|
|
@ -620,7 +620,7 @@ const removeRegistryVersion = (registry, identity, versionRecord) => {
|
|||
return null;
|
||||
}
|
||||
asset.versions = asset.versions.filter((version) => version?.versionId !== versionRecord.versionId);
|
||||
const nextCurrent = asset.versions.toSorted((first, second) => Number(second?.version || 0) - Number(first?.version || 0))[0] || null;
|
||||
const nextCurrent = [...asset.versions].sort((first, second) => Number(second?.version || 0) - Number(first?.version || 0))[0] || null;
|
||||
asset.currentVersion = nextCurrent?.version || null;
|
||||
asset.currentVersionId = nextCurrent?.versionId || null;
|
||||
asset.updatedAt = nowIso();
|
||||
|
|
@ -640,6 +640,7 @@ const buildModelListItem = (asset, ref, registry) => {
|
|||
status: asset.status || "ready",
|
||||
currentVersion: asset.currentVersion || null,
|
||||
currentVersionId: asset.currentVersionId || null,
|
||||
versions: Array.isArray(asset.versions) ? asset.versions : [],
|
||||
updatedAt: asset.updatedAt || asset.createdAt || null,
|
||||
ref: {
|
||||
id: ref.id,
|
||||
|
|
@ -1385,6 +1386,185 @@ const collectVersionUploadSrcs = async (versionRecord) => {
|
|||
return srcs;
|
||||
};
|
||||
|
||||
const inferVersionInfoFromUploadSrc = (src) => {
|
||||
const normalized = normalizeUploadSrcValue(src);
|
||||
const match = normalized ? normalized.match(/(?:^|\/)(ver_(\d+))(?:\/|$)/) : null;
|
||||
const version = match ? Number.parseInt(match[2], 10) : null;
|
||||
return {
|
||||
version: Number.isFinite(version) ? version : null,
|
||||
versionId: match ? match[1] : null
|
||||
};
|
||||
};
|
||||
|
||||
const hydrateAssetVersionRecord = async (versionRecord) => {
|
||||
const sourcePath = resolveUploadSrc(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src);
|
||||
const manifest = sourcePath ? await readJSONFile(manifestPathForSource(sourcePath)) : null;
|
||||
const inferred = inferVersionInfoFromUploadSrc(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src || manifest?.sourceSrc);
|
||||
const rawVersion = versionRecord?.version ?? manifest?.version ?? inferred.version;
|
||||
const version = Number(rawVersion);
|
||||
return {
|
||||
...(versionRecord || {}),
|
||||
...(manifest && typeof manifest === "object" ? manifest : {}),
|
||||
assetId: versionRecord?.assetId || manifest?.assetId || null,
|
||||
projectId: versionRecord?.projectId || manifest?.projectId || null,
|
||||
version: Number.isFinite(version) && version > 0 ? version : null,
|
||||
versionId: stringOrNull(versionRecord?.versionId || manifest?.versionId || inferred.versionId),
|
||||
originalFilename: stringOrNull(versionRecord?.originalFilename || manifest?.originalFilename) || (sourcePath ? path.basename(sourcePath) : null),
|
||||
sourceSrc: normalizeUploadSrcValue(manifest?.sourceSrc || versionRecord?.sourceSrc || versionRecord?.src),
|
||||
downloadSrc: normalizeUploadSrcValue(versionRecord?.downloadSrc || manifest?.downloadSrc || manifest?.sourceSrc || versionRecord?.sourceSrc || versionRecord?.src),
|
||||
artifactSrc: normalizeUploadSrcValue(manifest?.artifactSrc || versionRecord?.artifactSrc),
|
||||
metadataSrc: normalizeUploadSrcValue(manifest?.metadataSrc || versionRecord?.metadataSrc)
|
||||
};
|
||||
};
|
||||
|
||||
const mergeHydratedVersionRecords = (previous, next) => {
|
||||
if (!previous) {
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
...next,
|
||||
artifactSrc: next.artifactSrc || previous.artifactSrc || null,
|
||||
downloadSrc: next.downloadSrc || previous.downloadSrc || null,
|
||||
metadataSrc: next.metadataSrc || previous.metadataSrc || null,
|
||||
originalFilename: next.originalFilename || previous.originalFilename || null,
|
||||
sourceSrc: next.sourceSrc || previous.sourceSrc || null,
|
||||
status: previous.status === "ready" || next.status === "ready" ? "ready" : (next.status || previous.status || null),
|
||||
version: next.version || previous.version || null,
|
||||
versionId: next.versionId || previous.versionId || null
|
||||
};
|
||||
};
|
||||
|
||||
const publicModelVersionRecord = (req, versionRecord) => {
|
||||
const sourceSrc = normalizeUploadSrcValue(versionRecord?.sourceSrc || versionRecord?.src || versionRecord?.downloadSrc);
|
||||
const downloadSrc = normalizeUploadSrcValue(versionRecord?.downloadSrc || sourceSrc);
|
||||
const artifactSrc = normalizeUploadSrcValue(versionRecord?.artifactSrc);
|
||||
const metadataSrc = normalizeUploadSrcValue(versionRecord?.metadataSrc);
|
||||
const sourceFormat = (stringOrNull(versionRecord?.sourceFormat) || guessViewerTypeFromSrc(downloadSrc || sourceSrc || "", "file") || "")
|
||||
.replace(/^\./, "")
|
||||
.toLowerCase();
|
||||
const conversionRequired = sourceFormat && CONVERTIBLE_MODEL_FORMATS.has(`.${sourceFormat}`);
|
||||
const status = stringOrNull(versionRecord?.status) || (conversionRequired && !artifactSrc ? "conversion_required" : "ready");
|
||||
const artifactType = stringOrNull(versionRecord?.artifactType || versionRecord?.targetFormat);
|
||||
const viewerSrc = status === "ready" && artifactSrc ? artifactSrc : (downloadSrc || sourceSrc);
|
||||
const viewerType = artifactSrc ? (artifactType || "gltf") : guessViewerTypeFromSrc(viewerSrc || "", sourceFormat);
|
||||
const conversion = conversionRequired
|
||||
? {
|
||||
artifactSrc,
|
||||
artifactType: artifactType || null,
|
||||
componentTreeRequired: versionRecord?.componentTreeRequired !== false,
|
||||
message: stringOrNull(versionRecord?.message),
|
||||
metadataSrc,
|
||||
size: Number(versionRecord?.size) || 0,
|
||||
sourceFormat,
|
||||
sourceSrc,
|
||||
status,
|
||||
targetFormat: stringOrNull(versionRecord?.targetFormat) || "xkt",
|
||||
updatedAt: stringOrNull(versionRecord?.updatedAt || versionRecord?.uploadedAt)
|
||||
}
|
||||
: null;
|
||||
const previewAvailable = status === "ready" && !!viewerSrc && (!conversionRequired || !!artifactSrc);
|
||||
const viewerUrl = previewAvailable && viewerType
|
||||
? (() => {
|
||||
const url = new URL("/", `${getRequestBaseUrl(req)}/`);
|
||||
url.searchParams.set("url", publicUrlForUploadSrc(req, viewerSrc));
|
||||
url.searchParams.set("type", viewerType);
|
||||
url.searchParams.set("settingsSrc", publicUrlForUploadSrc(req, sourceSrc || downloadSrc || viewerSrc));
|
||||
if (versionRecord?.originalFilename) url.searchParams.set("name", versionRecord.originalFilename);
|
||||
return url.toString();
|
||||
})()
|
||||
: null;
|
||||
return {
|
||||
assetId: stringOrNull(versionRecord?.assetId),
|
||||
projectId: stringOrNull(versionRecord?.projectId),
|
||||
conversion,
|
||||
downloadUrl: downloadSrc ? publicUrlForUploadSrc(req, downloadSrc) : "",
|
||||
originalFilename: stringOrNull(versionRecord?.originalFilename) || (downloadSrc ? path.basename(downloadSrc) : "model"),
|
||||
previewAvailable,
|
||||
sha256: stringOrNull(versionRecord?.sha256),
|
||||
size: Number(versionRecord?.size) || 0,
|
||||
sourceSrc: sourceSrc || null,
|
||||
src: viewerSrc ? publicUrlForUploadSrc(req, viewerSrc) : "",
|
||||
status,
|
||||
type: viewerType || sourceFormat || "file",
|
||||
uploadedBy: stringOrNull(versionRecord?.uploadedBy),
|
||||
uploadedAt: stringOrNull(versionRecord?.uploadedAt || versionRecord?.updatedAt),
|
||||
version: Number(versionRecord?.version) || null,
|
||||
versionId: stringOrNull(versionRecord?.versionId),
|
||||
viewerUrl
|
||||
};
|
||||
};
|
||||
|
||||
const getAssetVersionHistory = async (req, identity) => {
|
||||
const normalized = normalizeAssetIdentity(identity);
|
||||
if (!normalized || normalized.legacy) {
|
||||
return null;
|
||||
}
|
||||
const assetDir = path.join(UPLOADS_DIR, normalized.projectId, normalized.assetId);
|
||||
const manifest = await readAssetManifest(assetDir);
|
||||
if (!manifest) {
|
||||
return null;
|
||||
}
|
||||
const hydratedVersions = await Promise.all(
|
||||
(Array.isArray(manifest.versions) ? manifest.versions : []).map(hydrateAssetVersionRecord)
|
||||
);
|
||||
const versionsByKey = new Map();
|
||||
hydratedVersions
|
||||
.filter((version) => Number.isFinite(Number(version?.version)) && Number(version.version) > 0)
|
||||
.forEach((version) => {
|
||||
const key = version.versionId || `version-${version.version}`;
|
||||
versionsByKey.set(key, mergeHydratedVersionRecords(versionsByKey.get(key), version));
|
||||
});
|
||||
const versions = [...versionsByKey.values()]
|
||||
.sort((first, second) => Number(first.version || 0) - Number(second.version || 0))
|
||||
.map((version) => publicModelVersionRecord(req, version));
|
||||
return {
|
||||
assetId: normalized.assetId,
|
||||
projectId: normalized.projectId,
|
||||
assetKey: normalized.assetKey,
|
||||
currentVersion: manifest.currentVersion || versions[versions.length - 1]?.version || null,
|
||||
currentVersionId: manifest.currentVersionId || versions[versions.length - 1]?.versionId || null,
|
||||
originalFilename: manifest.originalFilename || versions[versions.length - 1]?.originalFilename || null,
|
||||
updatedAt: manifest.updatedAt || null,
|
||||
versions
|
||||
};
|
||||
};
|
||||
|
||||
const resolveAssetVersionIdentityFromSearchParams = (searchParams) => {
|
||||
const direct = normalizeAssetIdentity({
|
||||
projectId: searchParams.get("projectId"),
|
||||
assetId: searchParams.get("assetId")
|
||||
});
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const src = normalizeUploadSrcValue(
|
||||
searchParams.get("src") ||
|
||||
searchParams.get("url") ||
|
||||
searchParams.get("sourceSrc") ||
|
||||
searchParams.get("downloadSrc") ||
|
||||
searchParams.get("artifactSrc")
|
||||
);
|
||||
return src ? getAssetIdentityFromSrc(src) : null;
|
||||
};
|
||||
|
||||
const handleGetUploadVersions = async (req, res, searchParams) => {
|
||||
const identity = resolveAssetVersionIdentityFromSearchParams(searchParams);
|
||||
if (!identity) {
|
||||
sendText(res, 400, "Invalid model asset identity");
|
||||
return;
|
||||
}
|
||||
const history = await getAssetVersionHistory(req, identity);
|
||||
if (!history) {
|
||||
sendText(res, 404, "Model asset versions not found");
|
||||
return;
|
||||
}
|
||||
sendJSON(res, 200, {
|
||||
ok: true,
|
||||
...history
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuthSession = (req, res) => {
|
||||
const session = getCurrentBimSession(req);
|
||||
sendJSON(res, 200, {
|
||||
|
|
@ -1533,6 +1713,10 @@ const handleGetShare = async (req, res, token) => {
|
|||
}
|
||||
const session = getCurrentBimSession(req);
|
||||
const authenticated = !!session;
|
||||
const versionHistory = await getAssetVersionHistory(req, {
|
||||
projectId: share.projectId,
|
||||
assetId: share.assetId
|
||||
}).catch(() => null);
|
||||
sendJSON(res, 200, {
|
||||
ok: true,
|
||||
mode: authenticated ? "standard" : "guest",
|
||||
|
|
@ -1546,7 +1730,9 @@ const handleGetShare = async (req, res, token) => {
|
|||
type: share.type || guessViewerTypeFromSrc(share.src, "xkt"),
|
||||
name: share.name || path.basename(share.src),
|
||||
assetId: share.assetId || null,
|
||||
versionId: share.versionId || null
|
||||
projectId: share.projectId || null,
|
||||
versionId: share.versionId || versionHistory?.currentVersionId || null,
|
||||
versions: versionHistory?.versions || []
|
||||
},
|
||||
share: {
|
||||
token: share.token,
|
||||
|
|
@ -1707,7 +1893,7 @@ const handleGetComments = async (req, res, url) => {
|
|||
const comments = items
|
||||
.filter((item) => item?.sourceKey === source.sourceKey && !item.deletedAt)
|
||||
.map(publicComment)
|
||||
.toSorted((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
|
||||
.sort((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
|
||||
sendJSON(res, 200, {
|
||||
ok: true,
|
||||
user: summarizeRegistryUser(user),
|
||||
|
|
@ -1873,7 +2059,7 @@ const handleGetModels = async (req, res) => {
|
|||
};
|
||||
return buildModelListItem(asset, ref, registry);
|
||||
})
|
||||
.toSorted((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
|
||||
.sort((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
|
||||
sendJSON(res, 200, {
|
||||
ok: true,
|
||||
user: summarizeRegistryUser(user),
|
||||
|
|
@ -2191,6 +2377,7 @@ const handleRawUpload = async (req, res, url) => {
|
|||
});
|
||||
sendJSON(res, 201, {
|
||||
assetId,
|
||||
projectId: uploadId,
|
||||
conversion,
|
||||
originalFilename: rawName,
|
||||
sha256,
|
||||
|
|
@ -2220,6 +2407,7 @@ const handleRawUpload = async (req, res, url) => {
|
|||
});
|
||||
sendJSON(res, 201, {
|
||||
assetId,
|
||||
projectId: uploadId,
|
||||
originalFilename: rawName,
|
||||
sha256,
|
||||
src,
|
||||
|
|
@ -2529,7 +2717,7 @@ const handleDeleteUploadVersion = async (req, res) => {
|
|||
? assetManifest.currentVersionId === versionToDelete.versionId
|
||||
: Number(assetManifest.currentVersion) === Number(versionToDelete.version);
|
||||
const nextCurrent = deletedCurrent
|
||||
? remainingVersions.toSorted((first, second) => Number(second.version || 0) - Number(first.version || 0))[0]
|
||||
? [...remainingVersions].sort((first, second) => Number(second.version || 0) - Number(first.version || 0))[0]
|
||||
: remainingVersions.find((version) => version.versionId === assetManifest.currentVersionId) || remainingVersions[remainingVersions.length - 1];
|
||||
const nextManifest = {
|
||||
...assetManifest,
|
||||
|
|
@ -3096,6 +3284,10 @@ const requestHandler = async (req, res) => {
|
|||
return handleDeleteUploadVersion(req, res);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/uploads/versions") {
|
||||
return handleGetUploadVersions(req, res, url.searchParams);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/conversions/status") {
|
||||
return handleConversionStatus(res, url.searchParams);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue