From 4ecebd5e79007881b36b771238c569f89bcb15e4 Mon Sep 17 00:00:00 2001 From: CODEX Date: Fri, 5 Jun 2026 01:58:06 +0300 Subject: [PATCH] beam: stabilize startup model loading --- frontend/dcViewer.js | 292 +++++++++++++++++++++++++++++++++++-------- server/index.js | 20 +++ 2 files changed, 260 insertions(+), 52 deletions(-) diff --git a/frontend/dcViewer.js b/frontend/dcViewer.js index 3e935d2..63c4feb 100644 --- a/frontend/dcViewer.js +++ b/frontend/dcViewer.js @@ -939,6 +939,40 @@ const log = (text, isError = false) => { if (isError) console.error(text); }; +const reportViewerEvent = (event, data = {}, level = "info") => { + const payload = { + event, + level, + data, + href: window.location.href, + ts: new Date().toISOString(), + }; + const logger = level === "error" ? console.error : console.log; + logger(`[beam-viewer] ${event}`, data); + fetch("/api/client-log", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + keepalive: true, + }).catch(() => {}); +}; + +window.addEventListener("error", (event) => { + reportViewerEvent("window-error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }, "error"); +}); + +window.addEventListener("unhandledrejection", (event) => { + reportViewerEvent("window-unhandled-rejection", { + reason: event.reason?.message || String(event.reason), + stack: event.reason?.stack, + }, "error"); +}); + const setStatus = (text) => { statusText.textContent = text; statusText.style.color = ""; @@ -964,6 +998,51 @@ const setLoading = (flag) => { } }; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const probeWebGLContext = (canvas, contextAttr = {}) => { + if (!canvas) return null; + const contextNames = ["webgl2", "experimental-webgl", "webgl"]; + for (const contextName of contextNames) { + try { + const gl = canvas.getContext(contextName, contextAttr); + if (gl) return { contextName, gl }; + } catch (_error) { + // Try next context type. + } + } + return null; +}; + +const waitForViewerCanvasReady = async (contextAttr = {}) => { + for (let attempt = 1; attempt <= 30; attempt++) { + const rect = mainCanvas?.getBoundingClientRect?.(); + const hasSize = !!rect && rect.width >= 32 && rect.height >= 32; + const webglProbe = hasSize ? probeWebGLContext(mainCanvas, contextAttr) : null; + if (hasSize && webglProbe?.gl) { + reportViewerEvent("webgl-ready", { + attempt, + contextName: webglProbe.contextName, + height: Math.round(rect.height), + width: Math.round(rect.width), + }); + return; + } + if (attempt === 1 || attempt % 10 === 0) { + reportViewerEvent("webgl-wait", { + attempt, + hasCanvas: !!mainCanvas, + height: rect ? Math.round(rect.height) : 0, + hasSize, + width: rect ? Math.round(rect.width) : 0, + webgl: !!webglProbe?.gl, + }); + } + await delay(150); + } + throw new Error("WebGL-контекст недоступен: браузер не выдал canvas context для BIM Viewer"); +}; + const updateNavCubeVisibility = () => { if (!navCubeCanvas) return; navCubeCanvas.style.display = activeModels.length ? "block" : "none"; @@ -2171,6 +2250,19 @@ const fetchJSON = async (url, options = {}) => { return data; }; +const fetchJSONWithTimeout = async (url, options = {}, timeoutMs = 5000) => { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchJSON(url, { + ...options, + signal: options.signal || controller.signal, + }); + } finally { + window.clearTimeout(timer); + } +}; + const uploadBuffer = async (bytes, filename) => { const safeName = filename || "upload.bin"; const params = new URLSearchParams({ filename: safeName }); @@ -2519,7 +2611,13 @@ const fetchModelSettings = async (src) => { const normalized = normalizeModelSettingsSrc(src); if (!normalized) return null; const params = new URLSearchParams({ src: normalized }); - return fetchJSON(`${MODEL_SETTINGS_API}?${params.toString()}`); + reportViewerEvent("model-settings-request", { src: normalized }); + const data = await fetchJSONWithTimeout(`${MODEL_SETTINGS_API}?${params.toString()}`, {}, 2500); + reportViewerEvent("model-settings-response", { + src: data?.sourceSrc || normalized, + hasViewerSettings: !!data?.viewerSettings, + }); + return data; }; const saveCurrentModelSettings = async () => { @@ -2691,9 +2789,13 @@ const initViewer = async () => { }; } + const viewerContextAttr = {}; + await waitForViewerCanvasReady(viewerContextAttr); + viewer = new Viewer({ canvasId: "viewerCanvas", transparent: true, + contextAttr: viewerContextAttr, dtxEnabled: true, colorTextureEnabled: true, }); @@ -3012,12 +3114,14 @@ const initViewer = async () => { } const loadModel = (options) => { + const debugOptions = options || {}; try { const { type, url, name = "", replace = true, id: forcedId, meta, edges } = options; const modelSettings = meta?.viewerSettings; const normalizedType = normalizeType(type, typeof url === "string" ? url : ""); const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : "")); if (!inferredType || !acceptByType[inferredType]) { + reportViewerEvent("load-model-unsupported", { type, url, name, normalizedType, inferredType }, "error"); showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl"); return null; } @@ -3035,6 +3139,14 @@ const initViewer = async () => { const isBlob = typeof url === "string" && url.startsWith("blob:"); const isMemory = typeof url === "string" && url.startsWith("memory:"); log(`loader: ${inferredType}, blob=${isBlob}, memory=${isMemory}, url=${url}`, false); + reportViewerEvent("load-model-start", { + inferredType, + isBlob, + isMemory, + name, + replace, + url, + }); const xktDataSource = inferredType === "xkt" ? (isBlob || isMemory ? createMemoryDataSource() : new sdk.XKTDefaultDataSource({ cacheBuster: !isBlob })) : null; @@ -3073,6 +3185,13 @@ const initViewer = async () => { modelTypes[id] = inferredType; currentModel.on("loaded", () => { + reportViewerEvent("model-loaded", { + id, + inferredType, + name, + objectCount: Object.keys(viewer.scene.objects || {}).length, + src: srcToLoad, + }); setTimeout(() => applyStoredTransformsToModel(id), 0); setTimeout(() => applyStoredTransformsToModel(id), 60); const hasModelCamera = !!modelSettings?.viewerState?.camera; @@ -3111,6 +3230,13 @@ const initViewer = async () => { }); currentModel.on("error", (err) => { + reportViewerEvent("model-error", { + id, + inferredType, + name, + error: err?.message || String(err), + src: srcToLoad, + }, "error"); showError(`Ошибка загрузки: ${err}`); setLoading(false); updateNavCubeVisibility(); @@ -3132,6 +3258,12 @@ const initViewer = async () => { return currentModel; } catch (e) { console.error(e); + reportViewerEvent("load-model-exception", { + type: debugOptions.type, + url: debugOptions.url, + name: debugOptions.name, + error: e?.message || String(e), + }, "error"); showError(e.message || "Ошибка загрузки"); setLoading(false); return null; @@ -3151,78 +3283,131 @@ const initViewer = async () => { const loadStartupModelFromQuery = async () => { const params = new URLSearchParams(window.location.search); const src = params.get("url") || params.get("src"); - if (!src) return; + reportViewerEvent("startup-query", { + search: window.location.search, + hasUrl: params.has("url"), + hasSrc: params.has("src"), + src, + typeParam: params.get("type"), + nameParam: 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)); if (!type) { + reportViewerEvent("startup-type-error", { src, name, typeParam: params.get("type") }, "error"); showError("Не удалось определить формат модели из URL"); return; } + dropZone?.classList.add("hidden"); + setStatus(`Подготовка: ${name}...`); + setLoading(true); + let settingsSrc = params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src; - let modelSettings = null; - try { - const settingsResponse = await fetchModelSettings(settingsSrc); - modelSettings = settingsResponse?.viewerSettings || null; - settingsSrc = settingsResponse?.sourceSrc || settingsSrc; - if (modelSettings) { - applyModelViewerSettings(modelSettings, { applyCamera: false }); + const applyQueryViewerOverrides = () => { + if (params.has("displayMode")) { + activeDisplayMode = params.get("displayMode") || "source"; + setDisplayModeControlState(); } - } catch (err) { - console.warn("model settings unavailable", err); - } + if (params.has("clayColor")) { + setClayColor(params.get("clayColor"), { apply: activeDisplayMode === "clay" }); + } + if (params.has("lightingMode") || params.has("light")) { + activeLightingMode = params.get("lightingMode") || params.get("light") || "ambient"; + applyLightingMode(); + } + if (params.has("navigationAxis") || params.has("axis")) { + activeNavigationAxis = params.get("navigationAxis") || params.get("axis") || "auto"; + applyNavigationAxis({ resetAuto: false }); + } + if (params.has("zoomSpeed")) { + setZoomSpeed(params.get("zoomSpeed")); + } + }; + applyQueryViewerOverrides(); - if (params.has("displayMode")) { - activeDisplayMode = params.get("displayMode") || "source"; - setDisplayModeControlState(); - } - if (params.has("clayColor")) { - setClayColor(params.get("clayColor"), { apply: activeDisplayMode === "clay" }); - } - if (params.has("lightingMode") || params.has("light")) { - activeLightingMode = params.get("lightingMode") || params.get("light") || "ambient"; - applyLightingMode(); - } - if (params.has("navigationAxis") || params.has("axis")) { - activeNavigationAxis = params.get("navigationAxis") || params.get("axis") || "auto"; - applyNavigationAxis({ resetAuto: false }); - } - if (params.has("zoomSpeed")) { - setZoomSpeed(params.get("zoomSpeed")); - } + const startupMeta = { + label: name, + source: "query", + settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src), + artifactSrc: normalizeModelSettingsSrc(src), + viewerSettings: null, + }; - if (modelSettings?.viewerState) { - modelSettings = { - ...modelSettings, + const applyFetchedStartupSettings = (settingsResponse) => { + const modelSettings = settingsResponse?.viewerSettings || null; + settingsSrc = settingsResponse?.sourceSrc || settingsSrc; + reportViewerEvent("startup-settings-loaded", { + settingsSrc, + hasViewerSettings: !!modelSettings, + }); + if (!modelSettings) return; + const normalizedSettingsSrc = normalizeModelSettingsSrc(settingsSrc) || startupMeta.settingsSrc; + startupMeta.settingsSrc = normalizedSettingsSrc; + startupMeta.viewerSettings = modelSettings; + const entry = activeModels.find((item) => item?.meta === startupMeta) || getPrimaryModelEntry(); + if (entry) { + entry.meta = { + ...(entry.meta || {}), + settingsSrc: normalizedSettingsSrc, + sourceSrc: normalizedSettingsSrc, + artifactSrc: startupMeta.artifactSrc, + viewerSettings: modelSettings, + }; + } + applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: true }); + applyQueryViewerOverrides(); + }; + + fetchModelSettings(settingsSrc) + .then(applyFetchedStartupSettings) + .catch((err) => { + console.warn("model settings unavailable", err); + reportViewerEvent("startup-settings-unavailable", { + settingsSrc, + error: err?.message || String(err), + }); + }); + + if (params.has("edges")) { + startupMeta.viewerSettings = { viewerState: { - ...modelSettings.viewerState, - displayMode: activeDisplayMode, - clayColor: activeClayColor, - lightingMode: activeLightingMode, - navigationAxis: activeNavigationAxis, - zoomSpeed: activeZoomSpeed, - edges: params.has("edges") ? params.get("edges") !== "false" : modelSettings.viewerState.edges + edges: params.get("edges") !== "false" } }; } + reportViewerEvent("startup-load-model", { + src, + name, + type, + settingsSrc, + hasViewerSettings: false, + }); loadModel({ type, url: src, name, replace: params.get("replace") !== "false", edges: params.has("edges") ? params.get("edges") !== "false" : undefined, - meta: { - label: name, - source: "query", - settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src), - artifactSrc: normalizeModelSettingsSrc(src), - viewerSettings: modelSettings, - }, + meta: startupMeta, }); }; + const startupModelLoadPromise = loadStartupModelFromQuery().catch((err) => { + console.warn("startup model load failed", err); + reportViewerEvent("startup-load-failed", { + message: err?.message || String(err), + stack: err?.stack, + }, "error"); + showError(err.message || "Не удалось загрузить модель из URL"); + }); + const loadProjectSnapshot = async (project) => { if (!project) { throw new Error("Нет данных проекта"); @@ -3969,14 +4154,17 @@ const loadProjectSnapshot = async (project) => { }); } - loadStartupModelFromQuery().catch((err) => { - console.warn("startup model load failed", err); - showError(err.message || "Не удалось загрузить модель из URL"); - }); + void startupModelLoadPromise; }; document.addEventListener("DOMContentLoaded", () => { - initViewer(); + initViewer().catch((err) => { + reportViewerEvent("init-viewer-error", { + message: err?.message || String(err), + stack: err?.stack, + }, "error"); + showError(err?.message || "Не удалось инициализировать viewer"); + }); }); // нижняя тулбар diff --git a/server/index.js b/server/index.js index bc40b1e..ddacb16 100644 --- a/server/index.js +++ b/server/index.js @@ -86,6 +86,11 @@ const sendText = (res, status, message) => { res.end(message); }; +const truncateLogValue = (value, maxLength = 2400) => { + const text = typeof value === "string" ? value : JSON.stringify(value); + return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text; +}; + const setCors = (res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); @@ -735,6 +740,9 @@ const handleDeleteProject = async (res, projectId) => { const requestHandler = async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); + if (req.method === "GET" && url.pathname === "/") { + console.log("[viewer-request]", truncateLogValue(`${url.pathname}${url.search}`)); + } if (req.method === "OPTIONS") { setCors(res); @@ -743,6 +751,18 @@ const requestHandler = async (req, res) => { return; } + if (req.method === "POST" && url.pathname === "/api/client-log") { + const payload = await parseJSONBody(req); + console.log( + "[viewer-client]", + payload?.level || "info", + payload?.event || "event", + truncateLogValue(payload || {}) + ); + sendJSON(res, 200, {ok: true}); + return; + } + if (url.pathname.startsWith("/api/projects")) { if (req.method === "GET" && url.pathname === "/api/projects") { return handleGetProjects(res, url.searchParams);