beam: stabilize startup model loading
This commit is contained in:
parent
6e1f5a7f08
commit
4ecebd5e79
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
// нижняя тулбар
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue