From 4638acc04ba9eb31e9ba25caf8141ba03d794707 Mon Sep 17 00:00:00 2001 From: CODEX Date: Thu, 18 Jun 2026 21:42:54 +0300 Subject: [PATCH] Integrate BIM viewer with NodeDC auth and sharing --- .gitignore | 2 + docker-compose.beam.yml | 12 + frontend/dcViewer.css | 34 +- frontend/dcViewer.js | 220 ++++- frontend/index.html | 70 +- server/README.md | 18 +- server/index.js | 2034 ++++++++++++++++++++++++++++++++++++++- 7 files changed, 2322 insertions(+), 68 deletions(-) diff --git a/.gitignore b/.gitignore index 8abe0ea..1a18526 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ node_modules __pycache__/ *.pyc server/data/uploads/ +server/data/shares/ +server/data/models/ diff --git a/docker-compose.beam.yml b/docker-compose.beam.yml index 455485f..e79edbb 100644 --- a/docker-compose.beam.yml +++ b/docker-compose.beam.yml @@ -3,8 +3,15 @@ services: image: node:22-alpine working_dir: /beam/server command: node index.js + restart: unless-stopped environment: PORT: "8080" + NODEDC_BIM_AUTH_REQUIRED: "${NODEDC_BIM_AUTH_REQUIRED:-0}" + NODEDC_BIM_PUBLIC_URL: "${NODEDC_BIM_PUBLIC_URL:-http://localhost:8080}" + NODEDC_LAUNCHER_BASE_URL: "${NODEDC_LAUNCHER_BASE_URL:-http://localhost:5173}" + NODEDC_LAUNCHER_INTERNAL_URL: "${NODEDC_LAUNCHER_INTERNAL_URL:-http://host.docker.internal:5173}" + NODEDC_INTERNAL_ACCESS_TOKEN: "${NODEDC_INTERNAL_ACCESS_TOKEN:-}" + NODEDC_BIM_ALLOWED_ORIGINS: "${NODEDC_BIM_ALLOWED_ORIGINS:-http://localhost:5173,http://localhost:8090}" ports: - "8080:8080" volumes: @@ -16,8 +23,13 @@ services: image: nodedc/bim-converter:local container_name: NodeDcBimConverter platform: linux/amd64 + restart: unless-stopped environment: NODEDC_BIM_CONVERTER_UPLOADS_DIR: /beam/uploads NODEDC_BIM_CONVERTER_INTERVAL_SECONDS: "10" + NODEDC_BIM_CONVERTER_MAX_ATTEMPTS: "3" + NODEDC_BIM_CONVERTER_TIMEOUT_SECONDS: "300" + NODEDC_BIM_CONVERTER_XCAF_TIMEOUT_SECONDS: "1200" + NODEDC_BIM_CONVERTER_CADQUERY_TIMEOUT_SECONDS: "1800" volumes: - ./server/data/uploads:/beam/uploads diff --git a/frontend/dcViewer.css b/frontend/dcViewer.css index 01ff30e..b6edbfe 100644 --- a/frontend/dcViewer.css +++ b/frontend/dcViewer.css @@ -1203,19 +1203,11 @@ header { } .save-project-modal .modal-close { - width: 32px; - height: 32px; - border-radius: 10px; - border: 1px solid var(--modal-border); - background: var(--modal-focus); - color: var(--text); - cursor: pointer; - font-size: 20px; - line-height: 1; + flex: 0 0 auto; } .save-project-modal .modal-close:hover { - background: var(--modal-border); + background: var(--env-glass-control-hover); } .save-project-modal .input-label { @@ -1239,6 +1231,11 @@ header { box-shadow: 0 0 0 2px rgba(255, 234, 0, 0.08); } +.save-project-modal input[readonly] { + cursor: text; + color: var(--text); +} + .save-project-modal .input-hint { font-size: 12px; color: var(--muted); @@ -1889,9 +1886,21 @@ header { align-items: center; justify-content: center; padding: var(--tb-icon-pad, 10px); + line-height: 1; transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease; } +.tb-btn svg { + display: block; + width: var(--tb-svg-size, 24px); + height: var(--tb-svg-size, 24px); + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + .tb-btn:hover { transform: translateY(-1px); border-color: var(--toolbar-outline, rgba(76, 224, 210, 0.7)); @@ -1902,6 +1911,11 @@ header { background: var(--toolbar-bg-active); } +body[data-viewer-mode="guest"] .bottom-toolbar, +body[data-viewer-mode="guest"] .inspector-restore-tray { + display: none !important; +} + .templates-panel { position: fixed; left: var(--templates-left, 72px); diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js index 3753381..6aa4a55 100644 --- a/frontend/dcViewer.js +++ b/frontend/dcViewer.js @@ -38,6 +38,7 @@ const bottomToolbar = document.getElementById("bottomToolbar"); const measureButton = document.querySelector('[data-action="measure"]'); const settingsButton = document.querySelector('[data-action="settings"]'); const templatesButton = document.querySelector('[data-action="templates"]'); +const shareButton = document.querySelector('[data-action="share"]'); const projectionToggle = document.querySelector('[data-action="projection"]'); const treeContainer = document.getElementById("treeContainer"); const displayModeControl = document.getElementById("displayModeControl"); @@ -70,6 +71,13 @@ const deleteProjectClose = document.getElementById("deleteProjectClose"); const deleteProjectCancel = document.getElementById("deleteProjectCancel"); const deleteProjectConfirm = document.getElementById("deleteProjectConfirm"); const deleteProjectError = document.getElementById("deleteProjectError"); +const shareModalBackdrop = document.getElementById("shareModalBackdrop"); +const shareModal = document.getElementById("shareModal"); +const shareModalClose = document.getElementById("shareModalClose"); +const shareModalCancel = document.getElementById("shareModalCancel"); +const shareLinkInput = document.getElementById("shareLinkInput"); +const shareCopyButton = document.getElementById("shareCopyButton"); +const shareError = document.getElementById("shareError"); const projectNameSection = document.getElementById("projectNameSection"); const projectNameInput = document.getElementById("projectNameInput"); const projectNameError = document.getElementById("projectNameError"); @@ -110,6 +118,8 @@ const applyPanelOffsets = () => { const API_BASE = "/api/projects"; const MODEL_SETTINGS_API = "/api/model-settings"; +const AUTH_SESSION_API = "/api/auth/session"; +const SHARES_API = "/api/shares"; const acceptByType = { xkt: ".xkt", @@ -212,6 +222,9 @@ let treeView = null; let currentBlobUrl = null; let modelCounter = 0; const activeModels = []; +let viewerMode = "standard"; +let runtimeSessionInfo = { authRequired: false, authenticated: false, canShare: true, user: null }; +let startupSharePayload = null; const memoryStore = new Map(); // url -> ArrayBuffer const blobUrls = []; let gizmo = null; @@ -421,6 +434,138 @@ const getPrimaryModelEntry = () => ( const hasModelSettingsTarget = () => !!getModelSettingsSrc(getPrimaryModelEntry()); +const isGuestMode = () => viewerMode === "guest"; + +const getShareTokenFromPath = () => { + const match = window.location.pathname.match(/^\/share\/([^/?#]+)/); + return match ? decodeURIComponent(match[1]) : null; +}; + +const applyRuntimeMode = () => { + document.body.dataset.viewerMode = viewerMode; + if (bottomToolbar) { + bottomToolbar.classList.toggle("hidden", isGuestMode()); + } + if (shareButton) { + const canShowShare = !isGuestMode() && runtimeSessionInfo.canShare && activeModels.length > 0; + shareButton.classList.toggle("hidden", !canShowShare); + shareButton.disabled = !canShowShare; + } + if (dropZone) { + dropZone.style.pointerEvents = isGuestMode() ? "none" : ""; + } +}; + +const initializeRuntimeMode = async () => { + const shareToken = getShareTokenFromPath(); + if (shareToken) { + viewerMode = "guest"; + try { + const response = await fetch(`${SHARES_API}/${encodeURIComponent(shareToken)}`, { + credentials: "include", + headers: { "Accept": "application/json" }, + }); + if (!response.ok) { + throw new Error(`Share link failed: HTTP ${response.status}`); + } + startupSharePayload = await response.json(); + } catch (err) { + console.error(err); + showError("Ссылка на модель недоступна"); + } + } + + try { + const response = await fetch(AUTH_SESSION_API, { + credentials: "include", + headers: { "Accept": "application/json" }, + }); + if (response.ok) { + runtimeSessionInfo = await response.json(); + } + } catch (err) { + console.warn("BIM session status unavailable", err); + } + + applyRuntimeMode(); +}; + +const getShareableModelPayload = () => { + const entry = getPrimaryModelEntry(); + if (!entry) { + return null; + } + const src = normalizeModelSettingsSrc(entry.meta?.artifactSrc || entry.src || entry.meta?.src); + const settingsSrc = getModelSettingsSrc(entry); + if (!src) { + return null; + } + return { + assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || 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, + }; +}; + +const closeShareModal = () => { + shareModal?.classList.add("hidden"); + shareModalBackdrop?.classList.add("hidden"); + if (shareError) shareError.textContent = ""; +}; + +const openShareModal = async () => { + if (isGuestMode()) return; + const payload = getShareableModelPayload(); + if (!payload) { + showError("Ссылку можно создать только для модели из BIM-хранилища"); + return; + } + shareModal?.classList.remove("hidden"); + shareModalBackdrop?.classList.remove("hidden"); + if (shareLinkInput) shareLinkInput.value = "Генерируем ссылку..."; + if (shareCopyButton) shareCopyButton.disabled = true; + if (shareError) shareError.textContent = ""; + + try { + const response = await fetch(SHARES_API, { + method: "POST", + credentials: "include", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const data = await response.json().catch(() => null); + if (!response.ok || !data?.url) { + throw new Error(data?.error || `Share create failed: HTTP ${response.status}`); + } + if (shareLinkInput) { + shareLinkInput.value = data.url; + shareLinkInput.select(); + } + if (shareCopyButton) shareCopyButton.disabled = false; + } catch (err) { + console.error(err); + if (shareLinkInput) shareLinkInput.value = ""; + if (shareError) shareError.textContent = err?.message || "Не удалось создать ссылку"; + } +}; + +const copyShareLink = async () => { + const value = shareLinkInput?.value || ""; + if (!value) return; + try { + await navigator.clipboard?.writeText(value); + } catch (_) { + shareLinkInput?.select(); + document.execCommand?.("copy"); + } +}; + const getDisplayModePreset = (mode) => { switch (mode) { case "clay": @@ -1091,27 +1236,28 @@ const updatePanelsVisibility = () => { if (treeContainer) treeContainer.classList.toggle("hidden", !hasModels); const showActions = hasModels || !!currentProjectMeta || !!lastAttemptedProjectId; if (saveProjectSection) { - saveProjectSection.classList.toggle("hidden", !showActions); + saveProjectSection.classList.toggle("hidden", isGuestMode() || !showActions); } if (projectNameSection) { - projectNameSection.classList.toggle("hidden", !(currentProjectMeta || lastAttemptedProjectId)); + projectNameSection.classList.toggle("hidden", isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId)); } if (saveProjectButton) { - saveProjectButton.disabled = !hasModels; + saveProjectButton.disabled = isGuestMode() || !hasModels; } if (saveModelSettingsButton) { - saveModelSettingsButton.disabled = !hasModels || !hasModelSettingsTarget(); + saveModelSettingsButton.disabled = isGuestMode() || !hasModels || !hasModelSettingsTarget(); } if (deleteProjectButton) { - deleteProjectButton.disabled = !(currentProjectMeta || lastAttemptedProjectId); + deleteProjectButton.disabled = isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId); } if (projectNameInput) { - const hasProject = !!(currentProjectMeta || lastAttemptedProjectId); + const hasProject = !isGuestMode() && !!(currentProjectMeta || lastAttemptedProjectId); projectNameInput.disabled = !hasProject; if (!hasProject) { projectNameInput.value = ""; } } + applyRuntimeMode(); }; const setInspectorCollapsed = (collapsed) => { @@ -2289,6 +2435,7 @@ const findProjectMetaById = (id) => { }; const submitProjectRename = async () => { + if (isGuestMode()) return; if (!projectNameInput || projectNameInput.disabled) return; const newName = (projectNameInput.value || "").trim(); const targetId = currentProjectMeta?.id || lastAttemptedProjectId; @@ -2581,6 +2728,10 @@ const fetchModelSettings = async (src) => { }; const saveCurrentModelSettings = async () => { + if (isGuestMode()) { + setStatus("Гостевой просмотр не сохраняет изменения"); + return; + } const entry = getPrimaryModelEntry(); const settingsSrc = getModelSettingsSrc(entry); if (!entry || !settingsSrc) { @@ -2697,6 +2848,7 @@ const initViewer = async () => { preloadHidden.remove(); } document.body.style.opacity = "1"; + await initializeRuntimeMode(); const sdk = await import(SDK_URL); sdkRef = sdk; @@ -3239,22 +3391,25 @@ const initViewer = async () => { const loadStartupModelFromQuery = async () => { const params = new URLSearchParams(window.location.search); - const src = params.get("url") || params.get("src"); + const sharedViewer = startupSharePayload?.viewer || null; + const src = sharedViewer?.src || params.get("url") || params.get("src"); reportViewerEvent("startup-query", { search: window.location.search, + mode: viewerMode, + hasShare: !!sharedViewer, hasUrl: params.has("url"), hasSrc: params.has("src"), src, - typeParam: params.get("type"), - nameParam: params.get("name"), + typeParam: sharedViewer?.type || params.get("type"), + nameParam: sharedViewer?.name || params.get("name"), }); if (!src) { reportViewerEvent("startup-no-src", { search: window.location.search }, "error"); return; } - const name = params.get("name") || src.split("/").pop() || "model"; - const type = normalizeType(params.get("type"), src) || normalizeType(guessTypeFromName(name)); + const name = sharedViewer?.name || params.get("name") || src.split("/").pop() || "model"; + const type = normalizeType(sharedViewer?.type || params.get("type"), src) || normalizeType(guessTypeFromName(name)); if (!type) { reportViewerEvent("startup-type-error", { src, name, typeParam: params.get("type") }, "error"); showError("Не удалось определить формат модели из URL"); @@ -3265,7 +3420,7 @@ const initViewer = async () => { setStatus(`Подготовка: ${name}...`); setLoading(true); - let settingsSrc = params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src; + let settingsSrc = sharedViewer?.settingsSrc || params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src; const applyQueryViewerOverrides = () => { if (params.has("displayMode")) { activeDisplayMode = params.get("displayMode") || "source"; @@ -3287,9 +3442,12 @@ const initViewer = async () => { const startupMeta = { label: name, - source: "query", + source: sharedViewer ? "share" : "query", settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src), artifactSrc: normalizeModelSettingsSrc(src), + assetId: sharedViewer?.assetId || null, + versionId: sharedViewer?.versionId || null, + shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(), viewerSettings: null, }; @@ -3463,6 +3621,7 @@ const loadProjectSnapshot = async (project) => { }; const openSaveModal = () => { + if (isGuestMode()) return; if (!activeModels.length && !currentProjectMeta) return; const defaultName = currentProjectMeta?.name || "Новый проект"; if (saveProjectNameInput) { @@ -3492,6 +3651,7 @@ const loadProjectSnapshot = async (project) => { }; const openDeleteModal = () => { + if (isGuestMode()) return; if (!currentProjectMeta && lastAttemptedProjectId) { const meta = findProjectMetaById(lastAttemptedProjectId) || { id: lastAttemptedProjectId, name: "", type: "project" }; currentProjectMeta = meta; @@ -3503,6 +3663,7 @@ const loadProjectSnapshot = async (project) => { }; const submitDeleteProject = async () => { + if (isGuestMode()) return; const targetId = currentProjectMeta?.id || lastAttemptedProjectId; if (!targetId) return; if (deleteProjectError) deleteProjectError.textContent = ""; @@ -3537,6 +3698,7 @@ const loadProjectSnapshot = async (project) => { }; const submitSaveProject = async (mode = "update") => { + if (isGuestMode()) return; if (!saveProjectNameInput) return; const trimmedName = saveProjectNameInput.value.trim(); if (!trimmedName) { @@ -3706,11 +3868,17 @@ const loadProjectSnapshot = async (project) => { } inspectorCollapseButton?.addEventListener("click", () => setInspectorCollapsed(true)); inspectorRestoreButton?.addEventListener("click", () => setInspectorCollapsed(false)); - loadProjectsList().catch(() => { - templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" }); - }); + if (!isGuestMode()) { + loadProjectsList().catch(() => { + templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" }); + }); + } const handleFile = (file) => { + if (isGuestMode()) { + setStatus("Гостевой просмотр не принимает загрузку файлов"); + return; + } const detected = guessTypeFromName(file.name); if (!detected) { showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl"); @@ -3744,6 +3912,7 @@ const loadProjectSnapshot = async (project) => { if (viewerWrapper) { ["dragenter", "dragover"].forEach((evt) => { viewerWrapper.addEventListener(evt, (e) => { + if (isGuestMode()) return; e.preventDefault(); e.stopPropagation(); dropZone.classList.add("active"); @@ -3752,6 +3921,7 @@ const loadProjectSnapshot = async (project) => { ["dragleave", "drop"].forEach((evt) => { viewerWrapper.addEventListener(evt, (e) => { + if (isGuestMode()) return; e.preventDefault(); e.stopPropagation(); dropZone.classList.remove("active"); @@ -3759,6 +3929,7 @@ const loadProjectSnapshot = async (project) => { }); viewerWrapper.addEventListener("drop", (e) => { + if (isGuestMode()) return; e.preventDefault(); e.stopPropagation(); const file = e.dataTransfer?.files?.[0]; @@ -3862,10 +4033,24 @@ const loadProjectSnapshot = async (project) => { closeDeleteModal(); } }); + shareModalCancel?.addEventListener("click", closeShareModal); + shareModalClose?.addEventListener("click", closeShareModal); + shareCopyButton?.addEventListener("click", copyShareLink); + shareModalBackdrop?.addEventListener("click", (e) => { + if (e.target === shareModalBackdrop) { + closeShareModal(); + } + }); document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if (e.target && ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return; + if (shareModal && !shareModal.classList.contains("hidden")) { + closeShareModal(); + e.preventDefault(); + e.stopPropagation(); + return; + } if (deleteProjectModal && !deleteProjectModal.classList.contains("hidden")) { closeDeleteModal(); e.preventDefault(); @@ -4106,6 +4291,7 @@ const loadProjectSnapshot = async (project) => { bottomToolbar.addEventListener("click", (e) => { const btn = e.target.closest(".tb-btn"); if (!btn) return; + if (isGuestMode()) return; const action = btn.dataset.action; if (action === "settings") { if (btn.classList.contains("active")) { @@ -4124,6 +4310,8 @@ const loadProjectSnapshot = async (project) => { setMeasureActive(!measureActive); } else if (action === "projection") { toggleProjection(); + } else if (action === "share") { + openShareModal(); } else if (action === "add") { fileInput.click(); } diff --git a/frontend/index.html b/frontend/index.html index f0c8a05..e4d44b7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -89,7 +89,7 @@ - +
@@ -220,7 +220,11 @@