Add GLB XKT pipeline and LOD controls

This commit is contained in:
CODEX
2026-06-25 16:24:48 +03:00
parent 761cb56fac
commit abf899fefd
7 changed files with 1144 additions and 37 deletions
+29 -1
View File
@@ -1242,6 +1242,32 @@ html.is-embedded-viewer #navCube {
stroke-linejoin: round;
}
.model-lod-generate-btn {
width: 100%;
min-height: 42px;
background: var(--modal-btn-primary, var(--accent));
color: var(--modal-btn-primary-contrast, #15170f);
box-shadow: 0 8px 24px color-mix(in srgb, var(--modal-btn-primary, var(--accent)) 25%, transparent);
}
.model-lod-generate-btn.is-generating,
.model-lod-generate-btn.is-generating:disabled {
cursor: progress;
opacity: 1;
animation: model-lod-generate-pulse 2.4s ease-in-out infinite;
box-shadow: 0 8px 26px color-mix(in srgb, var(--modal-btn-primary, var(--accent)) 28%, transparent);
}
@keyframes model-lod-generate-pulse {
0%,
100% {
background: var(--modal-btn-primary, var(--accent));
}
50% {
background: color-mix(in srgb, var(--modal-btn-primary, var(--accent)) 72%, white 28%);
}
}
.inspector-color-host {
min-width: 0;
}
@@ -3242,7 +3268,9 @@ html[data-share-startup] body:not([data-viewer-mode="standard"]) #projectNameSec
body[data-viewer-mode="guest"] #saveProjectSection,
html[data-share-startup] body:not([data-viewer-mode="standard"]) #saveProjectSection,
body[data-viewer-mode="guest"] #modelVersionUploadButton,
html[data-share-startup] body:not([data-viewer-mode="standard"]) #modelVersionUploadButton {
html[data-share-startup] body:not([data-viewer-mode="standard"]) #modelVersionUploadButton,
body[data-viewer-mode="guest"] #modelLodGenerateButton,
html[data-share-startup] body:not([data-viewer-mode="standard"]) #modelLodGenerateButton {
display: none !important;
}
+330 -11
View File
@@ -123,6 +123,9 @@ const modelVersionSelectHost = document.getElementById("modelVersionSelect");
const modelVersionHistoryButton = document.getElementById("modelVersionHistoryButton");
const modelVersionUploadButton = document.getElementById("modelVersionUploadButton");
const modelVersionFileInput = document.getElementById("modelVersionFileInput");
const modelLodControl = document.getElementById("modelLodControl");
const modelLodSelectHost = document.getElementById("modelLodSelect");
const modelLodGenerateButton = document.getElementById("modelLodGenerateButton");
const navigationAxisControl = document.getElementById("navigationAxisControl");
const navigationAxisSelectHost = document.getElementById("navigationAxisSelect");
const zoomSpeedControl = document.getElementById("zoomSpeedControl");
@@ -200,6 +203,7 @@ 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 UPLOAD_LODS_API = "/api/uploads/lods";
const SHARE_AUTH_GUEST_PARAM = "ndc_bim_guest";
const SHARE_PROMOTION_TIMEOUT_MS = 4500;
const SHARE_PROMOTION_RETRY_MS = 15000;
@@ -317,6 +321,9 @@ let startupSharePayload = null;
let modelVersionHistory = null;
let modelVersionHistoryLoading = false;
let modelVersionUploading = false;
let modelLodLoading = false;
let modelLodGenerating = false;
let modelLodPollTimer = null;
let versionHistoryModal = null;
let loadModelAsyncRef = null;
let versionModelCounter = 0;
@@ -344,6 +351,7 @@ let customDisplayColorControl = null;
let customDisplaySaturationSlider = null;
let lightingModeSelect = null;
let modelVersionSelect = null;
let modelLodSelect = null;
let navigationAxisSelect = null;
let zoomSpeedSlider = null;
let defaultCameraWorldAxis = null;
@@ -2277,7 +2285,8 @@ const getShareableModelPayload = () => {
if (!entry) {
return null;
}
const src = normalizeModelSettingsSrc(entry.meta?.artifactSrc || entry.src || entry.meta?.src);
const selectedLodSrc = getSelectedLodViewerSrc(entry);
const src = normalizeModelSettingsSrc(selectedLodSrc || entry.meta?.artifactSrc || entry.src || entry.meta?.src);
const settingsSrc = getModelSettingsSrc(entry);
if (!src) {
return null;
@@ -2287,9 +2296,10 @@ const getShareableModelPayload = () => {
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",
selectedLod: getCurrentLodIdForEntry(entry) || null,
settingsSrc: settingsSrc || src,
src,
type: entry.type || normalizeType(null, src),
type: normalizeType(null, src) || entry.meta?.artifactType || entry.type,
versionId: entry.meta?.versionId || entry.meta?.conversion?.versionId || null,
versions: Array.isArray(entry.meta?.versions) ? entry.meta.versions : [],
};
@@ -3483,6 +3493,7 @@ const updatePanelsVisibility = () => {
if (saveModelSettingsButton) {
saveModelSettingsButton.disabled = isGuestMode() || !hasModels || !hasModelSettingsTarget();
}
updateModelLodControl();
if (deleteProjectButton) {
deleteProjectButton.disabled = isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId);
}
@@ -4626,7 +4637,7 @@ const formatFetchFailureMessage = (url, res, text, data) => {
const formatApiNonJsonMessage = (url, res, text) => {
const status = res?.status ? `HTTP ${res.status}` : "HTTP";
if (/^\s*<!doctype html/i.test(text || "")) {
return `BIM API на этом адресе вернул HTML вместо JSON (${status}). На localhost:8080 сейчас нет backend /api/comments.`;
return `BIM API на этом адресе вернул HTML вместо JSON (${status}). Открой viewer через backend-профиль npm run serve, не через статический http-server.`;
}
return `BIM API вернул не-JSON ответ (${status}).`;
};
@@ -4760,6 +4771,252 @@ const getVersionViewerType = (version) => {
return normalizeType(version?.type, src) || normalizeType(guessTypeFromName(src || version?.originalFilename || ""));
};
const getLodKey = (lod) => lod?.id || lod?.artifactSrc || lod?.src || lod?.url || "lod";
const getLodViewerSrc = (lod) => normalizeVersionSource(lod?.src || lod?.url || lod?.artifactSrc || "");
const getLodViewerType = (lod) => {
const src = getLodViewerSrc(lod);
return normalizeType(lod?.type, src) || normalizeType(guessTypeFromName(src)) || "gltf";
};
const getEntryLods = (entry = getPrimaryModelEntry()) => {
const lods = entry?.meta?.lods;
return Array.isArray(lods) ? lods : [];
};
const getCurrentLodIdForEntry = (entry = getPrimaryModelEntry()) => {
const meta = entry?.meta || {};
const savedId = meta.selectedLodId ||
meta.selectedLod ||
meta.viewerSettings?.viewerState?.lod?.selectedId ||
meta.viewerSettings?.viewerState?.lod?.id ||
"";
if (savedId) return savedId;
const entrySrc = normalizeModelSettingsSrc(entry?.src || meta.artifactSrc || "");
const currentLod = getEntryLods(entry).find((lod) => {
const lodSrc = normalizeModelSettingsSrc(lod?.artifactSrc || lod?.src || lod?.url || "");
return lodSrc && lodSrc === entrySrc;
});
return currentLod?.id || getEntryLods(entry)[0]?.id || "";
};
const getSelectedLodRecord = (entry = getPrimaryModelEntry()) => {
const lods = getEntryLods(entry);
const currentId = getCurrentLodIdForEntry(entry);
return lods.find((lod) => currentId && lod?.id === currentId) || null;
};
const getSelectedLodViewerSrc = (entry = getPrimaryModelEntry()) => {
const lod = getSelectedLodRecord(entry);
return lod ? getLodViewerSrc(lod) : "";
};
const setEntryLodPayload = (entry, payload) => {
if (!entry || !payload) return;
const meta = entry.meta || {};
entry.meta = {
...meta,
sourceSrc: payload.sourceSrc || meta.sourceSrc || null,
settingsSrc: payload.sourceSrc || meta.settingsSrc || null,
artifactSrc: payload.artifactSrc || meta.artifactSrc || null,
artifactType: payload.artifactType || meta.artifactType || null,
xktConversion: payload.xktConversion || meta.xktConversion || null,
lodGeneration: payload.lodGeneration || null,
lods: Array.isArray(payload.lods) ? payload.lods : [],
selectedLod: payload.selectedLod || meta.selectedLod || null,
};
};
const isGlbLodCandidate = (entry = getPrimaryModelEntry()) => {
if (!entry) return false;
const meta = entry.meta || {};
const candidates = [
meta.sourceFormat,
meta.sourceSrc,
meta.settingsSrc,
meta.artifactSrc,
entry.src,
entry.name,
].filter(Boolean);
return candidates.some((value) => String(value).toLowerCase().includes(".glb") || String(value).toLowerCase() === "glb");
};
const buildLodParams = (entry = getPrimaryModelEntry()) => {
const params = new URLSearchParams();
const src = getModelSettingsSrc(entry) || entry?.meta?.sourceSrc || entry?.meta?.settingsSrc || "";
if (src) params.set("src", src);
return params;
};
const fetchModelLods = async (entry = getPrimaryModelEntry()) => {
const params = buildLodParams(entry);
if (!params.toString()) return null;
return fetchJSON(`${UPLOAD_LODS_API}?${params.toString()}`);
};
const formatLodOptionLabel = (lod) => {
const label = lod?.label || lod?.id || "LOD";
const size = formatFileSize(lod?.size);
return size ? `${label} · ${size}` : label;
};
const updateModelLodControl = () => {
const entry = getPrimaryModelEntry();
const lods = getEntryLods(entry);
const generation = entry?.meta?.lodGeneration || null;
const generationStatus = generation?.status || "";
const hasControl = !!entry && isGlbLodCandidate(entry) && (!isGuestMode() || lods.length > 0 || modelLodLoading);
modelLodControl?.classList.toggle("hidden", !hasControl);
if (!hasControl) {
modelLodGenerateButton?.classList.remove("is-generating");
return;
}
const hasReadyLods = lods.length > 0;
modelLodSelectHost?.classList.toggle("hidden", !hasReadyLods);
modelLodGenerateButton?.classList.toggle("hidden", hasReadyLods);
if (modelLodGenerateButton) {
const busy = modelLodGenerating || modelLodLoading || generationStatus === "queued" || generationStatus === "processing";
modelLodGenerateButton.classList.toggle("is-generating", busy);
modelLodGenerateButton.disabled = isGuestMode() || busy;
modelLodGenerateButton.textContent = busy
? "Генерируем LOD..."
: generationStatus === "failed"
? "Повторить генерацию LOD"
: "Сгенерировать LOD";
}
if (modelLodSelect) {
const options = hasReadyLods
? lods.map((lod) => ({ value: getLodKey(lod), label: formatLodOptionLabel(lod) }))
: [{ value: "loading", label: modelLodLoading ? "Загружаем LOD..." : "LOD не сгенерирован" }];
const currentValue = getCurrentLodIdForEntry(entry) || options[0]?.value;
modelLodSelect.setOptions(options, currentValue);
modelLodSelect.setDisabled(!hasReadyLods);
}
};
const scheduleModelLodPoll = () => {
if (modelLodPollTimer) {
window.clearTimeout(modelLodPollTimer);
}
modelLodPollTimer = window.setTimeout(() => {
modelLodPollTimer = null;
refreshModelLods({ silent: true }).catch(() => {});
}, 2500);
};
const refreshModelLods = async ({ silent = false } = {}) => {
const entry = getPrimaryModelEntry();
if (!entry || !isGlbLodCandidate(entry)) {
updateModelLodControl();
return null;
}
modelLodLoading = true;
updateModelLodControl();
try {
const payload = await fetchModelLods(entry);
setEntryLodPayload(entry, payload);
const status = payload?.lodGeneration?.status;
if (status === "queued" || status === "processing") {
scheduleModelLodPoll();
}
updateModelLodControl();
return payload;
} catch (err) {
if (!silent) console.warn("model LOD state unavailable", err);
updateModelLodControl();
return null;
} finally {
modelLodLoading = false;
updateModelLodControl();
}
};
const openModelLod = async (lod) => {
if (!loadModelAsyncRef) {
showError("Загрузчик модели еще не готов");
return;
}
const viewerSrc = getLodViewerSrc(lod);
const viewerType = getLodViewerType(lod);
if (!viewerSrc || !viewerType) {
setStatus("Этот LOD еще не готов к просмотру");
return;
}
const previousEntry = getPrimaryModelEntry();
const meta = {
...(previousEntry?.meta || {}),
artifactSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc,
selectedLod: lod.id || null,
selectedLodId: lod.id || null,
};
if (meta.viewerSettings?.viewerState) {
meta.viewerSettings = {
...meta.viewerSettings,
viewerState: {
...meta.viewerSettings.viewerState,
lod: {
selectedId: lod.id || null,
selectedSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc,
},
},
};
}
try {
await loadModelAsyncRef({
type: viewerType,
url: viewerSrc,
name: previousEntry?.name || meta.label || lod.label || "model",
replace: true,
preserveMeta: true,
id: previousEntry?.model?.id || "model",
meta,
});
updateModelLodControl();
setStatus(`Открыт LOD: ${lod.label || lod.id || "LOD"}`);
} catch (err) {
console.error(err);
showError(err.message || "Не удалось открыть LOD модели");
updateModelLodControl();
}
};
const generateModelLods = async () => {
const entry = getPrimaryModelEntry();
if (!entry || isGuestMode() || modelLodGenerating) return;
const settingsSrc = getModelSettingsSrc(entry);
if (!settingsSrc) {
setStatus("LOD можно сгенерировать только для модели из BIM-хранилища");
return;
}
modelLodGenerating = true;
updateModelLodControl();
try {
const payload = await fetchJSON(UPLOAD_LODS_API, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settingsSrc,
sourceSrc: entry.meta?.sourceSrc || settingsSrc,
artifactSrc: entry.meta?.artifactSrc || entry.src || null,
}),
});
setEntryLodPayload(entry, payload);
scheduleModelLodPoll();
setStatus("Генерация LOD поставлена в очередь");
} catch (err) {
console.error(err);
showError(err.message || "Не удалось запустить генерацию LOD");
} finally {
modelLodGenerating = false;
updateModelLodControl();
}
};
const getCurrentVersionIdForEntry = (entry = getPrimaryModelEntry()) => {
const meta = entry?.meta || {};
return meta.versionId || meta.conversion?.versionId || modelVersionHistory?.currentVersionId || null;
@@ -4898,6 +5155,8 @@ const openModelVersion = async (version) => {
sourceSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc,
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc,
artifactSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc,
artifactType: viewerType,
xktConversion: version.xktConversion || null,
viewerSettings: null,
};
try {
@@ -4905,6 +5164,14 @@ const openModelVersion = async (version) => {
if (settings?.viewerSettings) {
meta.viewerSettings = settings.viewerSettings;
}
if (settings) {
meta.lodGeneration = settings.lodGeneration || null;
meta.lods = Array.isArray(settings.lods) ? settings.lods : [];
meta.selectedLod = settings.selectedLod || null;
meta.artifactSrc = settings.artifactSrc || meta.artifactSrc;
meta.artifactType = normalizeType(null, settings.artifactSrc) || meta.artifactType || viewerType;
meta.xktConversion = settings.xktConversion || meta.xktConversion || null;
}
const previousModelId = previousEntry?.model?.id || null;
const nextModelId = makeVersionModelId(version);
if (previousModelId && transformStore[previousModelId]) {
@@ -4926,6 +5193,7 @@ const openModelVersion = async (version) => {
versions: modelVersionHistory?.versions || meta.versions || [],
};
updateModelVersionControl();
updateModelLodControl();
setStatus(`Открыта версия v${version.version || ""}`.trim());
} catch (err) {
console.error(err);
@@ -5282,9 +5550,10 @@ const buildProjectSnapshot = async (name) => {
for (const entry of activeModels) {
const model = entry.model;
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 preferredSrc = normalizeModelSettingsSrc(getSelectedLodViewerSrc(entry) || meta.artifactSrc || src) || src;
const modelType = normalizeType(null, preferredSrc) || normalizeType(entry.type, src) || normalizeType(guessTypeFromName(src)) || entry.type || null;
const versionIdentity = getModelVersionIdentity(entry);
const baseModel = {
id: model.id,
@@ -5299,7 +5568,12 @@ const buildProjectSnapshot = async (name) => {
metadataPath: meta.metadataPath || null,
settingsSrc: getModelSettingsSrc(entry) || null,
sourceSrc: meta.sourceSrc || meta.conversion?.sourceSrc || meta.manifest?.sourceSrc || null,
artifactSrc: meta.artifactSrc || null
artifactSrc: meta.artifactSrc || null,
artifactType: meta.artifactType || normalizeType(null, meta.artifactSrc) || null,
xktConversion: meta.xktConversion || null,
lodGeneration: meta.lodGeneration || null,
lods: Array.isArray(meta.lods) ? meta.lods : [],
selectedLod: getCurrentLodIdForEntry(entry) || meta.selectedLod || null
},
visible: model.visible !== false
};
@@ -5308,12 +5582,12 @@ const buildProjectSnapshot = async (name) => {
throw new Error("Не удалось определить тип модели для сохранения.");
}
if (isRemoteSrc(src)) {
models.push({ ...baseModel, src });
if (isRemoteSrc(preferredSrc)) {
models.push({ ...baseModel, src: preferredSrc });
continue;
}
const cacheKey = src || `${model.id}-${modelType}`;
const cacheKey = preferredSrc || src || `${model.id}-${modelType}`;
if (uploadCache.has(cacheKey)) {
models.push({ ...baseModel, src: uploadCache.get(cacheKey) });
continue;
@@ -5429,7 +5703,22 @@ const buildModelViewerSettings = () => ({
customDisplaySaturation: activeCustomDisplaySaturation,
lightingMode: activeLightingMode,
navigationAxis: activeNavigationAxis,
zoomSpeed: activeZoomSpeed
zoomSpeed: activeZoomSpeed,
lod: (() => {
const entry = getPrimaryModelEntry();
const selected = getSelectedLodRecord(entry);
const selectedSrc = getSelectedLodViewerSrc(entry);
return {
selectedId: selected?.id || getCurrentLodIdForEntry(entry) || null,
selectedSrc: normalizeModelSettingsSrc(selectedSrc) || selectedSrc || null,
levels: getEntryLods(entry).map((lod) => ({
id: lod.id || null,
label: lod.label || null,
artifactSrc: normalizeModelSettingsSrc(lod.artifactSrc || lod.src || lod.url || "") || null,
size: Number(lod.size) || 0
}))
};
})()
} : {},
design: settingsMenu?.getState ? settingsMenu.getState() : {}
});
@@ -5496,6 +5785,8 @@ const fetchModelSettings = async (src) => {
reportViewerEvent("model-settings-response", {
src: data?.sourceSrc || normalized,
hasViewerSettings: !!data?.viewerSettings,
lodCount: Array.isArray(data?.lods) ? data.lods.length : 0,
lodStatus: data?.lodGeneration?.status || null,
});
return data;
};
@@ -5530,8 +5821,12 @@ const saveCurrentModelSettings = async () => {
...(entry.meta || {}),
settingsSrc: saved?.sourceSrc || settingsSrc,
sourceSrc: saved?.sourceSrc || entry.meta?.sourceSrc || null,
lodGeneration: saved?.lodGeneration || entry.meta?.lodGeneration || null,
lods: Array.isArray(saved?.lods) ? saved.lods : (entry.meta?.lods || []),
selectedLod: saved?.selectedLod || entry.meta?.selectedLod || null,
viewerSettings: saved?.viewerSettings || payload.viewerSettings
};
updateModelLodControl();
setStatus("Настройки модели сохранены");
log(`Настройки модели сохранены: ${entry.name || entry.model?.id || settingsSrc}`, false);
} catch (err) {
@@ -6222,6 +6517,7 @@ const initViewer = async () => {
updateNavCubeVisibility();
updatePanelsVisibility();
refreshModelVersionHistory({ silent: true }).catch(() => {});
refreshModelLods({ silent: true }).catch(() => {});
// если есть сохранённые трансформы для модели — применим
applyStoredTransformsToModel(id);
@@ -6338,6 +6634,7 @@ const initViewer = async () => {
assetId: sharedViewer?.assetId || null,
projectId: sharedViewer?.projectId || null,
versionId: sharedViewer?.versionId || null,
selectedLod: sharedViewer?.selectedLod || null,
versions: Array.isArray(sharedViewer?.versions) ? sharedViewer.versions : [],
shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(),
viewerSettings: null,
@@ -6350,7 +6647,6 @@ const initViewer = async () => {
settingsSrc,
hasViewerSettings: !!modelSettings,
});
if (!modelSettings) return;
const normalizedSettingsSrc = normalizeModelSettingsSrc(settingsSrc) || startupMeta.settingsSrc;
startupMeta.settingsSrc = normalizedSettingsSrc;
startupMeta.viewerSettings = modelSettings;
@@ -6360,10 +6656,17 @@ const initViewer = async () => {
...(entry.meta || {}),
settingsSrc: normalizedSettingsSrc,
sourceSrc: normalizedSettingsSrc,
artifactSrc: startupMeta.artifactSrc,
artifactSrc: settingsResponse?.artifactSrc || startupMeta.artifactSrc,
artifactType: normalizeType(null, settingsResponse?.artifactSrc || startupMeta.artifactSrc) || startupMeta.artifactType || null,
xktConversion: settingsResponse?.xktConversion || startupMeta.xktConversion || null,
lodGeneration: settingsResponse?.lodGeneration || null,
lods: Array.isArray(settingsResponse?.lods) ? settingsResponse.lods : [],
selectedLod: settingsResponse?.selectedLod || startupMeta.selectedLod || null,
viewerSettings: modelSettings,
};
updateModelLodControl();
}
if (!modelSettings) return;
applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: true });
applyQueryViewerOverrides();
};
@@ -6734,6 +7037,19 @@ const loadProjectSnapshot = async (project) => {
});
updateModelVersionControl();
}
if (modelLodControl && modelLodSelectHost) {
modelLodSelect = createGlassSelect({
host: modelLodSelectHost,
value: "loading",
options: [{ value: "loading", label: "LOD не сгенерирован" }],
ariaLabel: "Уровень LOD",
onChange: (value) => {
const lod = getEntryLods(getPrimaryModelEntry()).find((item) => getLodKey(item) === value);
if (lod) openModelLod(lod);
},
});
updateModelLodControl();
}
if (displayModeControl && displayModeSelectHost) {
displayModeSelect = createGlassSelect({
host: displayModeSelectHost,
@@ -6841,6 +7157,9 @@ const loadProjectSnapshot = async (project) => {
event.target.value = "";
if (file) await uploadModelVersion(file);
});
modelLodGenerateButton?.addEventListener("click", () => {
generateModelLods();
});
registerFloatingWindowElement("inspector", sidePanelEl);
registerFloatingWindowElement("comments", commentsPanel);
registerFloatingWindowElement("settings", settingsPanel);
+7 -2
View File
@@ -231,7 +231,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/dcViewer.css?v=40">
<link rel="stylesheet" href="/dcViewer.css?v=41">
</head>
<body>
<header>
@@ -310,6 +310,11 @@
<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--full hidden" id="modelLodControl">
<div class="inspector-control-label">Уровни LOD</div>
<button class="primary-btn model-lod-generate-btn" id="modelLodGenerateButton" type="button">Сгенерировать LOD</button>
<div class="inspector-select-host hidden" id="modelLodSelect"></div>
</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>
@@ -697,6 +702,6 @@
<input id="fileInput" class="hidden" type="file"
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
<script type="module" src="/dcViewer.js?v=61"></script>
<script type="module" src="/dcViewer.js?v=63"></script>
</body>
</html>