Integrate BIM viewer with NodeDC auth and sharing

This commit is contained in:
CODEX 2026-06-18 21:42:54 +03:00
parent ef5b0a42a6
commit 4638acc04b
7 changed files with 2322 additions and 68 deletions

2
.gitignore vendored
View File

@ -4,3 +4,5 @@ node_modules
__pycache__/
*.pyc
server/data/uploads/
server/data/shares/
server/data/models/

View File

@ -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

View File

@ -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);

View File

@ -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();
}

View File

@ -89,7 +89,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=16">
<link rel="stylesheet" href="./dcViewer.css?v=17">
</head>
<body>
<header>
@ -220,7 +220,11 @@
<div id="saveProjectModal" class="save-project-modal hidden">
<div class="modal-header">
<div class="modal-title">Сохранить проект</div>
<button class="modal-close" id="saveProjectClose" aria-label="Закрыть">×</button>
<button class="modal-close inspector-collapse-btn" id="saveProjectClose" aria-label="Закрыть">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
</svg>
</button>
</div>
<label for="saveProjectName" class="input-label">Имя проекта</label>
<input type="text" id="saveProjectName" placeholder="Новый проект" autocomplete="off">
@ -236,7 +240,11 @@
<div id="deleteProjectModal" class="save-project-modal hidden">
<div class="modal-header">
<div class="modal-title">Удалить проект</div>
<button class="modal-close" id="deleteProjectClose" aria-label="Закрыть">×</button>
<button class="modal-close inspector-collapse-btn" id="deleteProjectClose" aria-label="Закрыть">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
</svg>
</button>
</div>
<div class="input-hint">Вы уверены, что хотите удалить проект?</div>
<div class="modal-actions">
@ -245,18 +253,64 @@
</div>
<div class="error-text" id="deleteProjectError"></div>
</div>
<div id="shareModalBackdrop" class="modal-backdrop hidden"></div>
<div id="shareModal" class="save-project-modal hidden">
<div class="modal-header">
<div class="modal-title">Ссылка на модель</div>
<button class="modal-close inspector-collapse-btn" id="shareModalClose" aria-label="Закрыть">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>
</svg>
</button>
</div>
<input type="text" id="shareLinkInput" readonly autocomplete="off">
<div class="modal-actions">
<button class="secondary-btn" id="shareModalCancel" type="button">Закрыть</button>
<button class="primary-btn" id="shareCopyButton" type="button" disabled>Скопировать</button>
</div>
<div class="error-text" id="shareError"></div>
</div>
<div id="bottomToolbar" class="bottom-toolbar">
<button class="tb-btn" data-action="settings" title="Настройки"></button>
<button class="tb-btn" data-action="templates" title="Примеры">📂</button>
<button class="tb-btn" data-action="measure" title="Линейка">📏</button>
<button class="tb-btn" data-action="settings" title="Настройки" aria-label="Настройки">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"></path>
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.37a1.7 1.7 0 0 0-1 .56V20a2 2 0 0 1-4 0v-.08a1.7 1.7 0 0 0-1-.56 1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.63 15a1.7 1.7 0 0 0-.56-1H4a2 2 0 0 1 0-4h.08a1.7 1.7 0 0 0 .56-1 1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.63a1.7 1.7 0 0 0 1-.56V4a2 2 0 0 1 4 0v.08a1.7 1.7 0 0 0 1 .56 1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.37 9c.18.38.38.73.56 1H20a2 2 0 0 1 0 4h-.08c-.18.27-.37.62-.52 1Z"></path>
</svg>
</button>
<button class="tb-btn" data-action="templates" title="Примеры" aria-label="Примеры">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3.5 6.5h6l2 2h9v8.75a2.25 2.25 0 0 1-2.25 2.25H5.75A2.25 2.25 0 0 1 3.5 17.25V6.5Z"></path>
<path d="M3.5 10h17"></path>
</svg>
</button>
<button class="tb-btn" data-action="measure" title="Линейка" aria-label="Линейка">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4.5 15.5 15.5 4.5l4 4-11 11-4-4Z"></path>
<path d="m8 12 2 2"></path>
<path d="m10.5 9.5 1.5 1.5"></path>
<path d="m13 7 2 2"></path>
</svg>
</button>
<button class="tb-btn" data-action="projection" title="Перспектива / аксонометрия">A</button>
<button class="tb-btn" data-action="add" title="Добавить файл"></button>
<button class="tb-btn" data-action="share" title="Поделиться моделью" aria-label="Поделиться моделью">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 14.5V4.75"></path>
<path d="m8.25 8.5 3.75-3.75 3.75 3.75"></path>
<path d="M7.25 11.25H6.5a2 2 0 0 0-2 2v4.25a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-4.25a2 2 0 0 0-2-2h-.75"></path>
</svg>
</button>
<button class="tb-btn" data-action="add" title="Добавить файл" aria-label="Добавить файл">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14"></path>
<path d="M5 12h14"></path>
</svg>
</button>
</div>
<input id="fileInput" class="hidden" type="file"
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
<script type="module" src="./dcViewer.js?v=21"></script>
<script type="module" src="./dcViewer.js?v=22"></script>
</body>
</html>

View File

@ -9,13 +9,17 @@
Статика: раздаётся из `../frontend/dist`, при отсутствии сборки — из `../frontend`.
Хранилище: `server/data/projects/index.json` (список) + `server/data/projects/proj_<id>.json` (полный проект). При старте создаются при необходимости.
Хранилище: `server/data/projects/index.json` (список) + `server/data/projects/proj_<id>.json` (полный проект).
Upload-артефакты лежат в `server/data/uploads`, публичные share-ссылки — в `server/data/shares/index.json`,
а пользовательские ссылки/ref-count моделей — в `server/data/models/registry.json`. При старте директории и индексы создаются при необходимости.
API:
- `GET /api/projects[?type=project|template]` — укороченный список из `index.json`.
- `GET /api/projects/:id` — полный JSON проекта.
- `POST /api/projects` — создать проект, генерируется `id`, `createdAt`, `updatedAt`, сохраняется файл и обновляется индекс.
- `POST /api/uploads?filename=<name>&projectId=<id>` — сохранить оригинальный файл модели в `server/data/uploads`.
- `DELETE /api/uploads/asset` — снять ссылку текущего пользователя на модель и физически удалить asset только если больше нет user/share refs.
- `DELETE /api/uploads/version` — удалить версию, если на неё нет чужих refs или публичных share-ссылок.
- `GET /api/conversions/status?src=<uploads/...>` — получить статус подготовки viewer-артефакта.
Для `.step/.stp` ответ помечается `conversion.status=conversion_required`: оригинал уже доступен для скачивания,
а preview должен появиться после работы `NodeDcBimConverter`, который готовит GLB и metadata/tree.
@ -25,3 +29,15 @@ API:
- `docker compose -f docker-compose.beam.yml up nodedc-bim-converter`
Поле `ownerId` пока всегда `null`, но оставлено для будущих пользователей/шаринга.
NODE.DC auth/share:
- `NODEDC_BIM_AUTH_REQUIRED=1` включает вход через Launcher handoff.
- `NODEDC_INTERNAL_ACCESS_TOKEN` должен совпадать с Launcher internal token.
- `NODEDC_LAUNCHER_BASE_URL` — публичный Launcher URL для редиректа на login/launch.
- `NODEDC_LAUNCHER_INTERNAL_URL` — внутренний URL Launcher для `/api/internal/handoff/consume`.
- `NODEDC_BIM_PUBLIC_URL` — публичный URL viewer-а для share-ссылок, на проде `https://bim.nodedc.ru`.
- `NODEDC_BIM_DATA_ROOT` — опциональный корень файлового storage вместо `server/data`.
- `POST /api/shares` создаёт публичную read-only ссылку на upload artifact; `/share/<token>` открывает viewer в guest mode.
- `GET /api/models` возвращает личные активные модели текущего пользователя.
- `POST /api/models/refs` сохраняет существующий upload artifact в личные модели текущего пользователя.
- `DELETE /api/models/refs` снимает личную ссылку и удаляет физические файлы только при отсутствии других refs/share.

File diff suppressed because it is too large Load Diff