Compare commits

..

4 Commits

Author SHA1 Message Date
CODEX 1a78dd0dc8 Add BIM legacy share transform fallback 2026-07-02 20:49:18 +03:00
CODEX c242e364bb Fix BIM share settings persistence on server 2026-07-02 20:47:20 +03:00
CODEX 2f1c7d979a Fix BIM startup loading shell 2026-07-02 20:20:44 +03:00
CODEX ef7179dc6c Fix BIM share transform persistence 2026-07-02 19:31:59 +03:00
4 changed files with 290 additions and 29 deletions

View File

@ -128,13 +128,15 @@ body {
margin: 0;
min-height: 100vh;
font-family: "Manrope", "Segoe UI", system-ui, -apple-system, sans-serif;
background: radial-gradient(circle at 20% 20%, rgba(76, 224, 210, 0.08), transparent 28%),
radial-gradient(circle at 82% 22%, rgba(116, 240, 229, 0.08), transparent 25%),
var(--bg-outer);
background: var(--bg-outer);
color: var(--text);
overflow: hidden;
}
body.app-starting {
background: linear-gradient(180deg, var(--loading-top), var(--loading-bottom));
}
html,
body,
.viewer-wrapper,
@ -339,6 +341,25 @@ html.is-embedded-viewer #navCube {
display: none;
}
body.app-starting header,
body.app-starting #dropZone,
body.app-starting #status,
body.app-starting #bottomToolbar,
body.app-starting #sectionToolbar,
body.app-starting #navCube,
body.app-starting .side-panel,
body.app-starting .comments-panel,
body.app-starting .templates-panel,
body.app-starting .settings-panel {
opacity: 0 !important;
pointer-events: none !important;
visibility: hidden !important;
}
body.app-starting #loader.hidden {
display: block;
}
.loader__figure,
.loader {
position: absolute;

View File

@ -29,6 +29,26 @@ const detectEmbeddedViewer = () => {
const embeddedViewer = detectEmbeddedViewer();
document.documentElement.classList.toggle("is-embedded-viewer", embeddedViewer);
let appShellSetupComplete = false;
let appStartupContentReady = false;
const revealAppShell = ({ force = false } = {}) => {
if (!force && (!appShellSetupComplete || !appStartupContentReady)) return;
appShellSetupComplete = true;
appStartupContentReady = true;
const preloadHidden = document.getElementById("preload-hidden");
if (preloadHidden) {
preloadHidden.remove();
}
document.body?.classList.remove("app-starting");
document.documentElement.classList.add("app-ready");
};
const markStartupContentReady = () => {
appStartupContentReady = true;
revealAppShell();
};
const dropZone = document.getElementById("dropZone");
const viewerWrapper = document.querySelector(".viewer-wrapper");
const headerEl = document.querySelector("header");
@ -2301,6 +2321,7 @@ const getShareableModelPayload = () => {
src,
type: normalizeType(null, src) || entry.meta?.artifactType || entry.type,
versionId: entry.meta?.versionId || entry.meta?.conversion?.versionId || null,
viewerSettings: buildModelViewerSettings(),
versions: Array.isArray(entry.meta?.versions) ? entry.meta.versions : [],
};
};
@ -4077,6 +4098,37 @@ const applyStoredTransformsToModel = (modelId) => {
return applied;
};
const getViewerSettingsTransformMap = (settings) => {
const transforms = settings?.viewerState?.transforms;
return transforms && typeof transforms === "object" && !Array.isArray(transforms)
? transforms
: null;
};
const ensureLoadedModelTransformAlias = (modelId, settings = null) => {
if (!modelId) return false;
const currentTransforms = transformStore[modelId];
if (currentTransforms && Object.keys(currentTransforms).length) {
return false;
}
const transforms = getViewerSettingsTransformMap(settings) || transformStore;
const preferredIds = Array.isArray(settings?.viewerState?.models)
? settings.viewerState.models.map((item) => item?.id).filter(Boolean)
: [];
const candidateIds = [...preferredIds, ...Object.keys(transforms || {})]
.filter((id, index, items) => id && id !== modelId && items.indexOf(id) === index);
const sourceId = candidateIds.find((id) => {
const value = transformStore[id] || transforms?.[id];
return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length;
});
if (!sourceId) return false;
transformStore[modelId] = safeClone(transformStore[sourceId] || transforms[sourceId]);
return true;
};
const getModelIdFromNode = (node) => {
const title = (node?.title || node?.text || node?.name || "").trim();
const firstObject = (node && collectObjectIds(node)[0]) || null;
@ -5532,9 +5584,7 @@ const snapshotVec = (v) => {
return null;
};
const buildProjectSnapshot = async (name) => {
const models = [];
const buildActiveTransformsSnapshot = () => {
const transformsCopy = safeClone(transformStore);
activeModels.forEach(({ model }) => {
if (!model || !model.id) return;
@ -5547,6 +5597,18 @@ const buildProjectSnapshot = async (name) => {
};
}
});
return transformsCopy;
};
const buildActiveModelStateSnapshot = () => activeModels.map((entry) => ({
id: entry.model?.id,
visible: entry.model?.visible !== false
}));
const buildProjectSnapshot = async (name) => {
const models = [];
const transformsCopy = buildActiveTransformsSnapshot();
for (const entry of activeModels) {
const model = entry.model;
@ -5637,10 +5699,7 @@ const buildProjectSnapshot = async (name) => {
ids: lastSelection.ids || [],
modelId: lastSelection.modelId || null,
},
models: activeModels.map((entry) => ({
id: entry.model?.id,
visible: entry.model?.visible !== false
})),
models: buildActiveModelStateSnapshot(),
transforms: transformsCopy
} : {};
@ -5704,6 +5763,8 @@ const buildModelViewerSettings = () => ({
lightingMode: activeLightingMode,
navigationAxis: activeNavigationAxis,
zoomSpeed: activeZoomSpeed,
models: buildActiveModelStateSnapshot(),
transforms: buildActiveTransformsSnapshot(),
lod: (() => {
const entry = getPrimaryModelEntry();
const selected = getSelectedLodRecord(entry);
@ -5885,7 +5946,10 @@ const applyViewerState = (state) => {
if (state.transforms && typeof state.transforms === "object") {
Object.keys(transformStore).forEach((k) => delete transformStore[k]);
Object.assign(transformStore, safeClone(state.transforms));
Object.keys(sceneModels).forEach((mid) => applyStoredTransformsToModel(mid));
Object.keys(sceneModels).forEach((mid) => {
ensureLoadedModelTransformAlias(mid, { viewerState: state });
applyStoredTransformsToModel(mid);
});
}
if (Array.isArray(state.models)) {
state.models.forEach((m) => {
@ -5923,12 +5987,6 @@ const initViewer = async () => {
setupNodeDCSessionSync();
setupNodeDCSessionWatcher();
const preloadHidden = document.getElementById("preload-hidden");
if (preloadHidden) {
preloadHidden.remove();
}
document.body.style.opacity = "1";
const sdk = await import(SDK_URL);
sdkRef = sdk;
const { Viewer, NavCubePlugin, DistanceMeasurementsPlugin, DistanceMeasurementsMouseControl, SectionPlanesPlugin } = sdk;
@ -6373,7 +6431,7 @@ const initViewer = async () => {
const loadModel = (options) => {
const debugOptions = options || {};
try {
const { type, url, name = "", replace = true, id: forcedId, meta, edges, preserveMeta = false } = options;
const { type, url, name = "", replace = true, id: forcedId, meta, edges, preserveMeta = false, startupReveal = false } = options;
const modelSettings = meta?.viewerSettings;
const normalizedType = normalizeType(type, typeof url === "string" ? url : "");
const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : ""));
@ -6445,6 +6503,7 @@ const initViewer = async () => {
}
sceneModels[id] = currentModel;
modelTypes[id] = inferredType;
ensureLoadedModelTransformAlias(id, modelSettings);
currentModel.on("loaded", () => {
reportViewerEvent("model-loaded", {
@ -6455,8 +6514,14 @@ const initViewer = async () => {
objectCount: Object.keys(viewer.scene.objects || {}).length,
src: srcToLoad,
});
setTimeout(() => applyStoredTransformsToModel(id), 0);
setTimeout(() => applyStoredTransformsToModel(id), 60);
setTimeout(() => {
ensureLoadedModelTransformAlias(id, modelSettings);
applyStoredTransformsToModel(id);
}, 0);
setTimeout(() => {
ensureLoadedModelTransformAlias(id, modelSettings);
applyStoredTransformsToModel(id);
}, 60);
const hasModelCamera = !!modelSettings?.viewerState?.camera;
if (!isLoadingSavedProject && !hasModelCamera) {
viewer.cameraFlight.flyTo({ aabb: viewer.scene.getAABB({}) });
@ -6466,6 +6531,9 @@ const initViewer = async () => {
}
setStatus(`Загружено: ${name || inferredType}`);
dropZone.classList.add("hidden");
if (startupReveal) {
markStartupContentReady();
}
setLoading(false);
if (inferredType === "las") {
snapshotPointCloudColors(id);
@ -6502,6 +6570,9 @@ const initViewer = async () => {
src: srcToLoad,
}, "error");
showError(`Ошибка загрузки: ${err}`);
if (startupReveal) {
markStartupContentReady();
}
setLoading(false);
updateNavCubeVisibility();
updatePanelsVisibility();
@ -6520,6 +6591,7 @@ const initViewer = async () => {
refreshModelLods({ silent: true }).catch(() => {});
// если есть сохранённые трансформы для модели — применим
ensureLoadedModelTransformAlias(id, modelSettings);
applyStoredTransformsToModel(id);
return currentModel;
} catch (e) {
@ -6531,6 +6603,9 @@ const initViewer = async () => {
error: e?.message || String(e),
}, "error");
showError(e.message || "Ошибка загрузки");
if (debugOptions.startupReveal) {
markStartupContentReady();
}
setLoading(false);
return null;
}
@ -6578,9 +6653,10 @@ const initViewer = async () => {
nameParam: sharedViewer?.name || params.get("name"),
});
if (!src) {
reportViewerEvent("startup-no-src", { search: window.location.search }, "error");
reportViewerEvent("startup-no-src", { search: window.location.search });
clearShareStartupPending();
setLoading(false);
markStartupContentReady();
return;
}
@ -6591,6 +6667,7 @@ const initViewer = async () => {
showError("Не удалось определить формат модели из URL");
clearShareStartupPending();
setLoading(false);
markStartupContentReady();
return;
}
@ -6600,6 +6677,9 @@ const initViewer = async () => {
clearShareStartupPending();
let settingsSrc = sharedViewer?.settingsSrc || params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src;
const sharedViewerSettings = sharedViewer?.viewerSettings && typeof sharedViewer.viewerSettings === "object"
? sharedViewer.viewerSettings
: null;
const applyQueryViewerOverrides = () => {
if (params.has("displayMode")) {
activeDisplayMode = params.get("displayMode") || "source";
@ -6637,11 +6717,11 @@ const initViewer = async () => {
selectedLod: sharedViewer?.selectedLod || null,
versions: Array.isArray(sharedViewer?.versions) ? sharedViewer.versions : [],
shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(),
viewerSettings: null,
viewerSettings: sharedViewerSettings,
};
const applyFetchedStartupSettings = (settingsResponse) => {
const modelSettings = settingsResponse?.viewerSettings || null;
const modelSettings = sharedViewerSettings || settingsResponse?.viewerSettings || null;
settingsSrc = settingsResponse?.sourceSrc || settingsSrc;
reportViewerEvent("startup-settings-loaded", {
settingsSrc,
@ -6683,7 +6763,9 @@ const initViewer = async () => {
if (params.has("edges")) {
startupMeta.viewerSettings = {
...(startupMeta.viewerSettings || {}),
viewerState: {
...(startupMeta.viewerSettings?.viewerState || {}),
edges: params.get("edges") !== "false"
}
};
@ -6694,16 +6776,20 @@ const initViewer = async () => {
name,
type,
settingsSrc,
hasViewerSettings: false,
hasViewerSettings: !!sharedViewerSettings,
});
loadModel({
const startupModel = loadModel({
type,
url: src,
name,
replace: params.get("replace") !== "false",
edges: params.has("edges") ? params.get("edges") !== "false" : undefined,
meta: startupMeta,
startupReveal: true,
});
if (!startupModel) {
markStartupContentReady();
}
};
const startupModelLoadPromise = loadStartupModelFromQuery().catch((err) => {
@ -6713,6 +6799,7 @@ const initViewer = async () => {
stack: err?.stack,
}, "error");
showError(err.message || "Не удалось загрузить модель из URL");
markStartupContentReady();
});
const loadProjectSnapshot = async (project) => {
@ -7687,6 +7774,8 @@ const loadProjectSnapshot = async (project) => {
});
}
appShellSetupComplete = true;
revealAppShell();
void startupModelLoadPromise;
};
@ -7697,6 +7786,7 @@ const loadProjectSnapshot = async (project) => {
stack: err?.stack,
}, "error");
showError(err?.message || "Не удалось инициализировать viewer");
revealAppShell({ force: true });
});
});

View File

@ -8,7 +8,31 @@
<link rel="alternate icon" href="/assets/favicon/favicon.ico">
<link rel="apple-touch-icon" href="/assets/favicon/apple-touch-icon.png">
<link rel="manifest" href="/assets/favicon/manifest.webmanifest.json">
<style id="preload-hidden">body{opacity:0;}</style>
<style id="preload-hidden">
html,
body {
background: #1c1c1c;
}
body.app-starting header,
body.app-starting #dropZone,
body.app-starting #status,
body.app-starting #bottomToolbar,
body.app-starting #sectionToolbar,
body.app-starting #navCube,
body.app-starting .side-panel,
body.app-starting .comments-panel,
body.app-starting .templates-panel,
body.app-starting .settings-panel {
opacity: 0 !important;
pointer-events: none !important;
visibility: hidden !important;
}
body.app-starting #loader {
display: block !important;
}
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;600;700&display=swap">
@ -231,9 +255,9 @@
<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=41">
<link rel="stylesheet" href="/dcViewer.css?v=42">
</head>
<body>
<body class="app-starting">
<header>
<button class="logo-btn" id="homeButton" type="button" title="Открыть NODE.DC Hub"></button>
<div class="toolbar"></div>
@ -702,6 +726,6 @@
<input id="fileInput" class="hidden" type="file"
accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl">
<script type="module" src="/dcViewer.js?v=63"></script>
<script type="module" src="/dcViewer.js?v=64"></script>
</body>
</html>

View File

@ -1940,6 +1940,121 @@ const handleLogout = (req, res, {redirect = true} = {}) => {
res.end("<!doctype html><html><head><meta charset='utf-8'></head><body>NODE.DC BIM session closed.</body></html>");
};
const normalizeViewerSettings = (value) => (
value && typeof value === "object" && !Array.isArray(value)
? value
: null
);
const viewerSettingsHaveTransforms = (settings) => {
const transforms = settings?.viewerState?.transforms;
return !!(
transforms &&
typeof transforms === "object" &&
!Array.isArray(transforms) &&
Object.keys(transforms).length
);
};
const modelRecordMatchesShare = (model, share) => {
const meta = model?.meta && typeof model.meta === "object" ? model.meta : {};
if (share?.assetId && meta.assetId === share.assetId) {
if (share.projectId && meta.projectId && meta.projectId !== share.projectId) {
return false;
}
if (share.versionId && meta.versionId && meta.versionId !== share.versionId) {
return false;
}
return true;
}
const haystack = [
model?.src,
meta.sourceSrc,
meta.settingsSrc,
meta.artifactSrc
].filter(Boolean).join("\n");
if (!haystack || !share?.assetId || !haystack.includes(share.assetId)) {
return false;
}
return !share.versionId || haystack.includes(share.versionId);
};
const readProjectViewerSettingsForShare = async (share) => {
if (!share?.assetId) return null;
let entries = [];
try {
entries = await fs.readdir(DATA_DIR, {withFileTypes: true});
} catch (_err) {
return null;
}
let best = null;
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".json") || entry.name === "index.json") {
continue;
}
const projectPath = path.join(DATA_DIR, entry.name);
const project = await readJSONFile(projectPath).catch(() => null);
if (!project || !Array.isArray(project.models)) {
continue;
}
if (!project.models.some((model) => modelRecordMatchesShare(model, share))) {
continue;
}
const settings = normalizeViewerSettings({
version: 1,
updatedAt: project.updatedAt || project.createdAt || nowIso(),
viewerState: project.viewerState || {},
design: project.design || null
});
if (!viewerSettingsHaveTransforms(settings)) {
continue;
}
const updatedAtMs = Date.parse(settings.updatedAt || "") || 0;
if (!best || updatedAtMs >= best.updatedAtMs) {
best = {settings, updatedAtMs};
}
}
return best?.settings || null;
};
const readViewerSettingsForModelSrc = async (src) => {
const manifestRef = await findManifestForModelSrc(src).catch(() => null);
if (!manifestRef || !existsSync(manifestRef.manifestPath)) {
return null;
}
const manifest = await readJSONFile(manifestRef.manifestPath);
return normalizeViewerSettings(manifest?.viewerSettings);
};
const mergeViewerSettingsTransforms = (base, fallback) => {
const normalizedBase = normalizeViewerSettings(base);
const normalizedFallback = normalizeViewerSettings(fallback);
if (!normalizedBase || viewerSettingsHaveTransforms(normalizedBase) || !viewerSettingsHaveTransforms(normalizedFallback)) {
return normalizedBase || normalizedFallback || null;
}
return {
...normalizedBase,
viewerState: {
...(normalizedBase.viewerState || {}),
transforms: normalizedFallback.viewerState.transforms,
models: normalizedFallback.viewerState.models || normalizedBase.viewerState?.models || []
}
};
};
const resolveShareViewerSettings = async (share) => {
const shareSettings = normalizeViewerSettings(share?.viewerSettings);
const manifestSettings = await readViewerSettingsForModelSrc(share?.settingsSrc || share?.src);
const base = shareSettings || manifestSettings;
if (viewerSettingsHaveTransforms(base)) {
return base;
}
const projectSettings = await readProjectViewerSettingsForShare(share);
return mergeViewerSettingsTransforms(base, projectSettings);
};
const handleCreateShare = async (req, res) => {
const session = await getValidatedBimSession(req, res);
if (BIM_AUTH_REQUIRED && !session) {
@ -1973,6 +2088,14 @@ const handleCreateShare = async (req, res) => {
const name = typeof payload?.name === "string" && payload.name.trim()
? payload.name.trim().slice(0, 240)
: path.basename(src);
const viewerSettings = await resolveShareViewerSettings({
...identity,
src,
settingsSrc,
versionId: typeof payload?.versionId === "string" ? payload.versionId : null,
selectedLod: stringOrNull(payload?.selectedLod),
viewerSettings: normalizeViewerSettings(payload?.viewerSettings)
});
const share = {
token,
access: "public-readonly",
@ -1984,6 +2107,7 @@ const handleCreateShare = async (req, res) => {
projectId: identity.projectId,
versionId: typeof payload?.versionId === "string" ? payload.versionId : null,
selectedLod: stringOrNull(payload?.selectedLod),
viewerSettings,
createdAt: now,
createdBy: session?.user ? {
id: session.user.id || null,
@ -2054,6 +2178,7 @@ const handleGetShare = async (req, res, token) => {
projectId: share.projectId,
assetId: share.assetId
}).catch(() => null);
const viewerSettings = await resolveShareViewerSettings(share);
sendJSON(res, 200, {
ok: true,
mode: authenticated ? "standard" : "guest",
@ -2073,6 +2198,7 @@ const handleGetShare = async (req, res, token) => {
projectId: share.projectId || null,
versionId: share.versionId || versionHistory?.currentVersionId || null,
selectedLod: share.selectedLod || null,
viewerSettings,
versions: versionHistory?.versions || []
},
share: {