beam: stabilize startup model loading

This commit is contained in:
CODEX 2026-06-05 01:58:06 +03:00
parent 6e1f5a7f08
commit 4ecebd5e79
2 changed files with 260 additions and 52 deletions

View File

@ -939,6 +939,40 @@ const log = (text, isError = false) => {
if (isError) console.error(text); 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) => { const setStatus = (text) => {
statusText.textContent = text; statusText.textContent = text;
statusText.style.color = ""; 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 = () => { const updateNavCubeVisibility = () => {
if (!navCubeCanvas) return; if (!navCubeCanvas) return;
navCubeCanvas.style.display = activeModels.length ? "block" : "none"; navCubeCanvas.style.display = activeModels.length ? "block" : "none";
@ -2171,6 +2250,19 @@ const fetchJSON = async (url, options = {}) => {
return data; 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 uploadBuffer = async (bytes, filename) => {
const safeName = filename || "upload.bin"; const safeName = filename || "upload.bin";
const params = new URLSearchParams({ filename: safeName }); const params = new URLSearchParams({ filename: safeName });
@ -2519,7 +2611,13 @@ const fetchModelSettings = async (src) => {
const normalized = normalizeModelSettingsSrc(src); const normalized = normalizeModelSettingsSrc(src);
if (!normalized) return null; if (!normalized) return null;
const params = new URLSearchParams({ src: normalized }); 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 () => { const saveCurrentModelSettings = async () => {
@ -2691,9 +2789,13 @@ const initViewer = async () => {
}; };
} }
const viewerContextAttr = {};
await waitForViewerCanvasReady(viewerContextAttr);
viewer = new Viewer({ viewer = new Viewer({
canvasId: "viewerCanvas", canvasId: "viewerCanvas",
transparent: true, transparent: true,
contextAttr: viewerContextAttr,
dtxEnabled: true, dtxEnabled: true,
colorTextureEnabled: true, colorTextureEnabled: true,
}); });
@ -3012,12 +3114,14 @@ const initViewer = async () => {
} }
const loadModel = (options) => { const loadModel = (options) => {
const debugOptions = options || {};
try { try {
const { type, url, name = "", replace = true, id: forcedId, meta, edges } = options; const { type, url, name = "", replace = true, id: forcedId, meta, edges } = options;
const modelSettings = meta?.viewerSettings; const modelSettings = meta?.viewerSettings;
const normalizedType = normalizeType(type, typeof url === "string" ? url : ""); const normalizedType = normalizeType(type, typeof url === "string" ? url : "");
const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : "")); const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : ""));
if (!inferredType || !acceptByType[inferredType]) { if (!inferredType || !acceptByType[inferredType]) {
reportViewerEvent("load-model-unsupported", { type, url, name, normalizedType, inferredType }, "error");
showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl"); showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl");
return null; return null;
} }
@ -3035,6 +3139,14 @@ const initViewer = async () => {
const isBlob = typeof url === "string" && url.startsWith("blob:"); const isBlob = typeof url === "string" && url.startsWith("blob:");
const isMemory = typeof url === "string" && url.startsWith("memory:"); const isMemory = typeof url === "string" && url.startsWith("memory:");
log(`loader: ${inferredType}, blob=${isBlob}, memory=${isMemory}, url=${url}`, false); log(`loader: ${inferredType}, blob=${isBlob}, memory=${isMemory}, url=${url}`, false);
reportViewerEvent("load-model-start", {
inferredType,
isBlob,
isMemory,
name,
replace,
url,
});
const xktDataSource = inferredType === "xkt" const xktDataSource = inferredType === "xkt"
? (isBlob || isMemory ? createMemoryDataSource() : new sdk.XKTDefaultDataSource({ cacheBuster: !isBlob })) ? (isBlob || isMemory ? createMemoryDataSource() : new sdk.XKTDefaultDataSource({ cacheBuster: !isBlob }))
: null; : null;
@ -3073,6 +3185,13 @@ const initViewer = async () => {
modelTypes[id] = inferredType; modelTypes[id] = inferredType;
currentModel.on("loaded", () => { 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), 0);
setTimeout(() => applyStoredTransformsToModel(id), 60); setTimeout(() => applyStoredTransformsToModel(id), 60);
const hasModelCamera = !!modelSettings?.viewerState?.camera; const hasModelCamera = !!modelSettings?.viewerState?.camera;
@ -3111,6 +3230,13 @@ const initViewer = async () => {
}); });
currentModel.on("error", (err) => { currentModel.on("error", (err) => {
reportViewerEvent("model-error", {
id,
inferredType,
name,
error: err?.message || String(err),
src: srcToLoad,
}, "error");
showError(`Ошибка загрузки: ${err}`); showError(`Ошибка загрузки: ${err}`);
setLoading(false); setLoading(false);
updateNavCubeVisibility(); updateNavCubeVisibility();
@ -3132,6 +3258,12 @@ const initViewer = async () => {
return currentModel; return currentModel;
} catch (e) { } catch (e) {
console.error(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 || "Ошибка загрузки"); showError(e.message || "Ошибка загрузки");
setLoading(false); setLoading(false);
return null; return null;
@ -3151,28 +3283,33 @@ const initViewer = async () => {
const loadStartupModelFromQuery = async () => { const loadStartupModelFromQuery = async () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const src = params.get("url") || params.get("src"); 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 name = params.get("name") || src.split("/").pop() || "model";
const type = normalizeType(params.get("type"), src) || normalizeType(guessTypeFromName(name)); const type = normalizeType(params.get("type"), src) || normalizeType(guessTypeFromName(name));
if (!type) { if (!type) {
reportViewerEvent("startup-type-error", { src, name, typeParam: params.get("type") }, "error");
showError("Не удалось определить формат модели из URL"); showError("Не удалось определить формат модели из URL");
return; return;
} }
let settingsSrc = params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src; dropZone?.classList.add("hidden");
let modelSettings = null; setStatus(`Подготовка: ${name}...`);
try { setLoading(true);
const settingsResponse = await fetchModelSettings(settingsSrc);
modelSettings = settingsResponse?.viewerSettings || null;
settingsSrc = settingsResponse?.sourceSrc || settingsSrc;
if (modelSettings) {
applyModelViewerSettings(modelSettings, { applyCamera: false });
}
} catch (err) {
console.warn("model settings unavailable", err);
}
let settingsSrc = params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src;
const applyQueryViewerOverrides = () => {
if (params.has("displayMode")) { if (params.has("displayMode")) {
activeDisplayMode = params.get("displayMode") || "source"; activeDisplayMode = params.get("displayMode") || "source";
setDisplayModeControlState(); setDisplayModeControlState();
@ -3191,38 +3328,86 @@ const initViewer = async () => {
if (params.has("zoomSpeed")) { if (params.has("zoomSpeed")) {
setZoomSpeed(params.get("zoomSpeed")); setZoomSpeed(params.get("zoomSpeed"));
} }
};
applyQueryViewerOverrides();
if (modelSettings?.viewerState) { const startupMeta = {
modelSettings = { label: name,
...modelSettings, source: "query",
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src),
artifactSrc: normalizeModelSettingsSrc(src),
viewerSettings: null,
};
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: { viewerState: {
...modelSettings.viewerState, edges: params.get("edges") !== "false"
displayMode: activeDisplayMode,
clayColor: activeClayColor,
lightingMode: activeLightingMode,
navigationAxis: activeNavigationAxis,
zoomSpeed: activeZoomSpeed,
edges: params.has("edges") ? params.get("edges") !== "false" : modelSettings.viewerState.edges
} }
}; };
} }
reportViewerEvent("startup-load-model", {
src,
name,
type,
settingsSrc,
hasViewerSettings: false,
});
loadModel({ loadModel({
type, type,
url: src, url: src,
name, name,
replace: params.get("replace") !== "false", replace: params.get("replace") !== "false",
edges: params.has("edges") ? params.get("edges") !== "false" : undefined, edges: params.has("edges") ? params.get("edges") !== "false" : undefined,
meta: { meta: startupMeta,
label: name,
source: "query",
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src),
artifactSrc: normalizeModelSettingsSrc(src),
viewerSettings: modelSettings,
},
}); });
}; };
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) => { const loadProjectSnapshot = async (project) => {
if (!project) { if (!project) {
throw new Error("Нет данных проекта"); throw new Error("Нет данных проекта");
@ -3969,14 +4154,17 @@ const loadProjectSnapshot = async (project) => {
}); });
} }
loadStartupModelFromQuery().catch((err) => { void startupModelLoadPromise;
console.warn("startup model load failed", err);
showError(err.message || "Не удалось загрузить модель из URL");
});
}; };
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
initViewer(); initViewer().catch((err) => {
reportViewerEvent("init-viewer-error", {
message: err?.message || String(err),
stack: err?.stack,
}, "error");
showError(err?.message || "Не удалось инициализировать viewer");
});
}); });
// нижняя тулбар // нижняя тулбар

View File

@ -86,6 +86,11 @@ const sendText = (res, status, message) => {
res.end(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) => { const setCors = (res) => {
res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); 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 requestHandler = async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); 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") { if (req.method === "OPTIONS") {
setCors(res); setCors(res);
@ -743,6 +751,18 @@ const requestHandler = async (req, res) => {
return; 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 (url.pathname.startsWith("/api/projects")) {
if (req.method === "GET" && url.pathname === "/api/projects") { if (req.method === "GET" && url.pathname === "/api/projects") {
return handleGetProjects(res, url.searchParams); return handleGetProjects(res, url.searchParams);