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);
};
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,28 +3283,33 @@ 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;
}
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 });
}
} catch (err) {
console.warn("model settings unavailable", err);
}
dropZone?.classList.add("hidden");
setStatus(`Подготовка: ${name}...`);
setLoading(true);
let settingsSrc = params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src;
const applyQueryViewerOverrides = () => {
if (params.has("displayMode")) {
activeDisplayMode = params.get("displayMode") || "source";
setDisplayModeControlState();
@ -3191,38 +3328,86 @@ const initViewer = async () => {
if (params.has("zoomSpeed")) {
setZoomSpeed(params.get("zoomSpeed"));
}
};
applyQueryViewerOverrides();
if (modelSettings?.viewerState) {
modelSettings = {
...modelSettings,
const startupMeta = {
label: name,
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: {
...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");
});
});
// нижняя тулбар

View File

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