const SDK_VERSION = "2.6.78"; // соответствует package.json
const SDK_URL = "./lib/dc-camera-context.es.js?v=5";
import { createLogo } from "./src/ui/logo.js";
import { applyToolbarVars } from "./src/ui/toolbar.js?v=3";
import { createTemplatesMenu } from "./src/ui/templatesMenu.js?v=3";
import { createSettingsMenu } from "./src/ui/settingsMenu.js?v=15";
import { createMeasurementModal } from "./src/ui/measurementModal.js";
import { createGlassSelect } from "./src/ui/glassSelect.js";
import { createColorControl } from "./src/ui/controls/color.js";
import { createSliderControl } from "./src/ui/controls/slider.js";
import "./theme.js?v=3";
import { TransformGizmo } from "./transformGizmo.js";
import { createObjectModal } from "./src/ui/objectModal.js";
const detectEmbeddedViewer = () => {
const params = new URLSearchParams(window.location.search || "");
const embeddedParam = params.get("embed") || params.get("embedded") || params.get("iframe");
const explicitEmbedded = embeddedParam !== null && !/^(0|false|no|standalone)$/i.test(embeddedParam);
let framed = false;
try {
framed = window.self !== window.top;
} catch (_) {
framed = true;
}
return explicitEmbedded || framed;
};
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");
const sidePanelEl = document.querySelector(".side-panel");
const inspectorCollapseButton = document.getElementById("inspectorCollapseButton");
const inspectorRestoreTray = document.getElementById("inspectorRestoreTray");
const inspectorRestoreButton = document.getElementById("inspectorRestoreButton");
const fileInput = document.getElementById("fileInput");
const chooseFileBtn = document.getElementById("chooseFile");
const examplesBar = document.getElementById("examplesBar");
const templatesPanel = document.getElementById("templatesPanel");
const settingsPanel = document.getElementById("settingsPanel");
const measureModalBackdrop = document.getElementById("measureModalBackdrop");
const measureModal = document.getElementById("measureModal");
const objectModalBackdrop = document.getElementById("objectModalBackdrop");
const objectModal = document.getElementById("objectModal");
const statusText = document.getElementById("statusText");
const fitViewBtn = document.getElementById("fitView");
const toggleEdges = document.getElementById("toggleEdges");
const toggleTransparent = document.getElementById("toggleTransparent");
const addMode = document.getElementById("addMode");
const homeButton = document.getElementById("homeButton");
const bottomToolbar = document.getElementById("bottomToolbar");
const measureButton = document.querySelector('[data-action="measure"]');
const settingsButton = document.querySelector('[data-action="settings"]');
const templatesButton = document.querySelector('[data-action="templates"]');
const shareButton = document.querySelector('[data-action="share"]');
const sectionButton = document.querySelector('[data-action="section"]');
const commentsButton = document.querySelector('[data-action="comments"]');
const sectionToolbar = document.getElementById("sectionToolbar");
const sectionOverviewCanvas = document.getElementById("sectionPlanesOverview");
const projectionToggle = document.querySelector('[data-action="projection"]');
const commentsPanel = document.getElementById("commentsPanel");
const commentsPanelHeader = document.getElementById("commentsPanelHeader");
const commentsCollapseButton = document.getElementById("commentsCollapseButton");
const commentsRestoreTray = document.getElementById("commentsRestoreTray");
const commentsRestoreButton = document.getElementById("commentsRestoreButton");
const commentsList = document.getElementById("commentsList");
const commentTargetsLayer = document.getElementById("commentTargetsLayer");
const commentContextMenu = document.getElementById("commentContextMenu");
const commentModalBackdrop = document.getElementById("commentModalBackdrop");
const commentModal = document.getElementById("commentModal");
const commentModalHeader = document.getElementById("commentModalHeader");
const commentModalClose = document.getElementById("commentModalClose");
const commentModalExpandButton = document.getElementById("commentModalExpandButton");
const commentModalSubtitle = document.getElementById("commentModalSubtitle");
const commentModalEditedMeta = document.getElementById("commentModalEditedMeta");
const commentTitleInput = document.getElementById("commentTitleInput");
const commentBodyInput = document.getElementById("commentBodyInput");
const commentDropzone = document.getElementById("commentDropzone");
const commentAttachButton = document.getElementById("commentAttachButton");
const commentAttachmentInput = document.getElementById("commentAttachmentInput");
const commentAttachmentGrid = document.getElementById("commentAttachmentGrid");
const commentSubitemsSection = document.getElementById("commentSubitemsSection");
const commentSubitemsList = document.getElementById("commentSubitemsList");
const commentAddSubitemButton = document.getElementById("commentAddSubitemButton");
const commentSubitemMenu = document.getElementById("commentSubitemMenu");
const commentThreadList = document.getElementById("commentThreadList");
const commentReplyInput = document.getElementById("commentReplyInput");
const commentReplyAttachButton = document.getElementById("commentReplyAttachButton");
const commentReplyAttachmentInput = document.getElementById("commentReplyAttachmentInput");
const DEFAULT_LAUNCHER_URL = "https://hub.nodedc.ru/";
const getLauncherUrl = () => {
const configured = typeof window !== "undefined" ? window.__BIMDC_LAUNCHER_URL__ : "";
const raw = typeof configured === "string" && configured.trim()
? configured.trim()
: DEFAULT_LAUNCHER_URL;
try {
return new URL(raw, window.location.href).toString();
} catch {
return DEFAULT_LAUNCHER_URL;
}
};
const commentReplyAttachmentGrid = document.getElementById("commentReplyAttachmentGrid");
const commentModalError = document.getElementById("commentModalError");
const commentCancelButton = document.getElementById("commentCancelButton");
const commentApplyButton = document.getElementById("commentApplyButton");
const commentSaveButton = document.getElementById("commentSaveButton");
const commentApplyInlineButton = document.getElementById("commentApplyInlineButton");
const treeContainer = document.getElementById("treeContainer");
const displayModeControl = document.getElementById("displayModeControl");
const displayModeSelectHost = document.getElementById("displayModeSelect");
const customDisplayColorRow = document.getElementById("customDisplayColorRow");
const customDisplayColorControlHost = document.getElementById("customDisplayColorControl");
const customDisplaySaturationControl = document.getElementById("customDisplaySaturationControl");
const lightingModeControl = document.getElementById("lightingModeControl");
const lightingModeSelectHost = document.getElementById("lightingModeSelect");
const modelVersionControl = document.getElementById("modelVersionControl");
const modelVersionSelectHost = document.getElementById("modelVersionSelect");
const modelVersionHistoryButton = document.getElementById("modelVersionHistoryButton");
const modelVersionUploadButton = document.getElementById("modelVersionUploadButton");
const modelVersionFileInput = document.getElementById("modelVersionFileInput");
const modelLodControl = document.getElementById("modelLodControl");
const modelLodSelectHost = document.getElementById("modelLodSelect");
const modelLodGenerateButton = document.getElementById("modelLodGenerateButton");
const navigationAxisControl = document.getElementById("navigationAxisControl");
const navigationAxisSelectHost = document.getElementById("navigationAxisSelect");
const zoomSpeedControl = document.getElementById("zoomSpeedControl");
const logList = document.getElementById("logList");
const loaderEl = document.getElementById("loader");
const navCubeCanvas = document.getElementById("navCube");
const mainCanvas = document.getElementById("viewerCanvas");
const saveProjectSection = document.getElementById("saveProjectSection");
const saveProjectButton = document.getElementById("saveProjectButton");
const saveModelSettingsButton = document.getElementById("saveModelSettingsButton");
const saveProjectBackdrop = document.getElementById("saveProjectBackdrop");
const saveProjectModal = document.getElementById("saveProjectModal");
const saveProjectClose = document.getElementById("saveProjectClose");
const saveProjectCancel = document.getElementById("saveProjectCancel");
const saveProjectConfirm = document.getElementById("saveProjectConfirm");
const saveProjectDuplicate = document.getElementById("saveProjectDuplicate");
const saveProjectNameInput = document.getElementById("saveProjectName");
const saveProjectError = document.getElementById("saveProjectError");
const saveProjectHint = document.getElementById("saveProjectHint");
const deleteProjectButton = document.getElementById("deleteProjectButton");
const deleteProjectBackdrop = document.getElementById("deleteProjectBackdrop");
const deleteProjectModal = document.getElementById("deleteProjectModal");
const deleteProjectClose = document.getElementById("deleteProjectClose");
const deleteProjectCancel = document.getElementById("deleteProjectCancel");
const deleteProjectConfirm = document.getElementById("deleteProjectConfirm");
const deleteProjectError = document.getElementById("deleteProjectError");
const shareModalBackdrop = document.getElementById("shareModalBackdrop");
const shareModal = document.getElementById("shareModal");
const shareModalClose = document.getElementById("shareModalClose");
const shareModalCancel = document.getElementById("shareModalCancel");
const shareLinkInput = document.getElementById("shareLinkInput");
const shareCopyButton = document.getElementById("shareCopyButton");
const shareError = document.getElementById("shareError");
const projectNameSection = document.getElementById("projectNameSection");
const projectNameInput = document.getElementById("projectNameInput");
const projectNameError = document.getElementById("projectNameError");
const transformInputs = {
posX: document.getElementById("posX"),
posY: document.getElementById("posY"),
posZ: document.getElementById("posZ"),
rotX: document.getElementById("rotX"),
rotY: document.getElementById("rotY"),
rotZ: document.getElementById("rotZ"),
scaleX: document.getElementById("scaleX"),
scaleY: document.getElementById("scaleY"),
scaleZ: document.getElementById("scaleZ"),
};
const globalInputs = {
x: document.getElementById("globalX"),
y: document.getElementById("globalY"),
z: document.getElementById("globalZ"),
};
const modeButtons = document.querySelectorAll("[data-mode]");
const PANEL_OFFSETS = {
templates: { left: 52, bottom: 12 },
settings: { left: 52, bottom: 12 },
measurement: { left: 52, bottom: 12 },
};
const applyPanelOffsets = () => {
const root = document.documentElement?.style;
if (!root) return;
root.setProperty("--templates-left", `${PANEL_OFFSETS.templates.left}px`);
root.setProperty("--templates-bottom", `${PANEL_OFFSETS.templates.bottom}px`);
root.setProperty("--settings-left", `${PANEL_OFFSETS.settings.left}px`);
root.setProperty("--settings-bottom", `${PANEL_OFFSETS.settings.bottom}px`);
root.setProperty("--measure-left", `${PANEL_OFFSETS.measurement.left}px`);
root.setProperty("--measure-bottom", `${PANEL_OFFSETS.measurement.bottom}px`);
};
const API_BASE = "/api/projects";
const MODEL_SETTINGS_API = "/api/model-settings";
const AUTH_SESSION_API = "/api/auth/session";
const SHARES_API = "/api/shares";
const COMMENTS_API = "/api/comments";
const UPLOAD_VERSIONS_API = "/api/uploads/versions";
const UPLOAD_LODS_API = "/api/uploads/lods";
const SHARE_AUTH_GUEST_PARAM = "ndc_bim_guest";
const SHARE_PROMOTION_TIMEOUT_MS = 4500;
const SHARE_PROMOTION_RETRY_MS = 15000;
const SESSION_STATUS_WATCH_INTERVAL_MS = 2500;
const NODEDC_SESSION_LOGOUT_EVENT_TYPE = "nodedc:session:logout";
const NODEDC_SESSION_LOGIN_EVENT_TYPE = "nodedc:session:login";
const acceptByType = {
xkt: ".xkt",
gltf: ".glb,.gltf",
glb: ".glb,.gltf",
bim: ".bim",
las: ".las,.laz",
laz: ".las,.laz",
obj: ".obj",
stl: ".stl",
};
const DEFAULT_EXAMPLES = [
{
id: "ifc-xkt",
name: "IFC / XKT пример",
description: "Стандартная BIM-модель Schependomlaan.",
type: "xkt",
url: "./examples/ifc-xkt/Schependomlaan.ifc.xkt",
},
{
id: "glb",
name: "GLB пример",
description: "Городской фрагмент MAP в формате glTF/GLB.",
type: "gltf",
url: "./examples/glb/MAP.glb",
},
{
id: "pointcloud",
name: "Point Cloud пример",
description: "Облако точек Nalls Pumpkin Hill (LAZ).",
type: "las",
url: "./examples/pointcloud/Nalls_Pumpkin_Hill.laz",
},
{
id: "bim",
name: "BIM (.bim) пример",
description: "Тестовая BIM-модель BlenderHouse в формате .bim.",
type: "bim",
url: "./examples/bim/BlenderHouse.bim",
},
];
const templateFallbacks = DEFAULT_EXAMPLES.map((ex) => ({
id: ex.id,
name: ex.name,
type: "template",
updatedAt: null
}));
const DISPLAY_MODE_OPTIONS = [
{ value: "source", label: "Исходный" },
{ value: "clay", label: "Clay" },
{ value: "white", label: "Белый" },
{ value: "custom", label: "Кастом" },
{ value: "edges", label: "Рёбра" },
{ value: "xray", label: "Рентген" },
];
const DEFAULT_CUSTOM_DISPLAY_COLOR = "#ffffff";
const DEFAULT_CUSTOM_DISPLAY_SATURATION = 100;
const NAVIGATION_AXIS_OPTIONS = [
{ value: "auto", label: "Авто" },
{ value: "x-up", label: "X" },
{ value: "y-up", label: "Y" },
{ value: "z-up", label: "Z" },
];
const LIGHTING_MODE_OPTIONS = [
{ value: "standard", label: "Стандарт" },
{ value: "soft", label: "Мягкий" },
{ value: "technical", label: "Тех." },
];
const NAVIGATION_AXIS_PRESETS = {
"y-up": {
worldAxis: [1, 0, 0, 0, 1, 0, 0, 0, 1],
up: [0, 1, 0],
forward: [0, 0, 1],
},
"z-up": {
worldAxis: [1, 0, 0, 0, 0, 1, 0, -1, 0],
up: [0, 0, 1],
forward: [0, -1, 0],
},
"x-up": {
worldAxis: [0, 1, 0, 1, 0, 0, 0, 0, -1],
up: [1, 0, 0],
forward: [0, 0, -1],
},
};
let templatesMenu = null;
let settingsMenu = null;
let pendingDesignState = null;
let measurementModal = null;
applyPanelOffsets();
let viewer = null;
let treeView = null;
let currentBlobUrl = null;
let modelCounter = 0;
const activeModels = [];
let viewerMode = "standard";
let runtimeSessionInfo = { authRequired: false, authenticated: false, canShare: true, user: null };
let startupSharePayload = null;
let modelVersionHistory = null;
let modelVersionHistoryLoading = false;
let modelVersionUploading = false;
let modelLodLoading = false;
let modelLodGenerating = false;
let modelLodPollTimer = null;
let versionHistoryModal = null;
let loadModelAsyncRef = null;
let versionModelCounter = 0;
let emptyWheelNavigationRestoreTimer = null;
let lastSurfaceDollyFactor = null;
let lastSurfaceDollyFactorAt = 0;
const MODEL_LOAD_TIMEOUT_MS = 120000;
const GUEST_TOOLBAR_ACTIONS = new Set(["measure", "section", "projection"]);
const memoryStore = new Map(); // url -> ArrayBuffer
const blobUrls = [];
let gizmo = null;
const transformStore = {}; // modelId -> objectId -> {position, rotation, scale}
const modelTypes = {}; // modelId -> normalized type
const sceneModels = {}; // modelId -> SceneModel
const pointCloudColorSnapshots = new Map(); // modelId -> [{ layer, colors, workingColors }]
let activeDisplayMode = "source";
let activeLightingMode = "technical";
let activeNavigationAxis = "auto";
let activeZoomSpeed = 20;
let activeCustomDisplayColor = DEFAULT_CUSTOM_DISPLAY_COLOR;
let activeCustomDisplaySaturation = DEFAULT_CUSTOM_DISPLAY_SATURATION;
let inspectorCollapsed = false;
let displayModeSelect = null;
let customDisplayColorControl = null;
let customDisplaySaturationSlider = null;
let lightingModeSelect = null;
let modelVersionSelect = null;
let modelLodSelect = null;
let navigationAxisSelect = null;
let zoomSpeedSlider = null;
let defaultCameraWorldAxis = null;
let defaultCameraUp = null;
let currentSelection = null; // { modelId, objectId }
const customTrees = new Map(); // modelId -> element
let navCubePlugin = null;
let measurePlugin = null;
let measureControl = null;
let measureActive = false;
let activeMeasurementId = null;
let sectionPlanesPlugin = null;
let activeSectionPlaneId = null;
let sectionPlaneCounter = 0;
let sectionModeActive = false;
let activeSectionAxis = null;
let commentsModeActive = false;
let commentsCollapsed = false;
let commentRecords = [];
let commentSourcesLoaded = new Set();
let commentContextTarget = null;
let commentModalMode = "create";
let commentModalCommentId = null;
let commentModalExpanded = false;
let commentSaveInFlight = false;
let activeCommentId = null;
let hoverCommentId = null;
let commentDraftAttachments = [];
let commentReplyAttachments = [];
let commentDraftSubitems = [];
let commentTargetRaf = 0;
let commentLongPressTimer = null;
let commentLongPressStart = null;
let suppressNextCanvasClick = false;
let floatingWindowOrder = [];
const floatingWindowElements = new Map();
const WORKSPACE_WINDOW_Z_BASE = 30080;
const WORKSPACE_WINDOW_Z_STEP = 10;
let objectContextModal = null;
let mathUtil = null;
let selectionState = null; // { ids, modelId, pivotCenter, baseTransforms: Map, transform }
let lastSelection = { ids: [], modelId: null };
let activeTransformTarget = null; // последняя цель для инпутов трансформаций
let sdkRef = null;
let projectIndex = { templates: [], projects: [] };
let currentProjectMeta = null;
let isLoadingSavedProject = false;
const uploadCache = new Map(); // srcKey -> uploaded src
let suppressEdgeReload = false;
let lastAttemptedProjectId = null;
const treeModelIds = new Set();
const CAMERA_CONTROL_SETTINGS = {
keyboardDollyRate: 45,
dollyInertia: 0,
dollyMinSpeed: 0.04,
dollyProximityThreshold: 30,
};
const ZOOM_SPEED_PERCENT_MIN = 5;
const ZOOM_SPEED_PERCENT_MAX = 100;
const ZOOM_DOLLY_RATE_MIN = 1;
const ZOOM_DOLLY_RATE_MAX = 100;
const ZOOM_DOLLY_RESPONSE_EXPONENT = 1.45;
const LAST_SURFACE_DOLLY_FACTOR_TTL = 1500;
const normalizeZoomSpeed = (value) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 20;
return Math.min(ZOOM_SPEED_PERCENT_MAX, Math.max(ZOOM_SPEED_PERCENT_MIN, Math.round(numeric)));
};
const getZoomDollyRate = (value) => {
const normalized = normalizeZoomSpeed(value);
const range = ZOOM_SPEED_PERCENT_MAX - ZOOM_SPEED_PERCENT_MIN;
const t = range > 0 ? (normalized - ZOOM_SPEED_PERCENT_MIN) / range : 0;
return ZOOM_DOLLY_RATE_MIN + (Math.pow(t, ZOOM_DOLLY_RESPONSE_EXPONENT) * (ZOOM_DOLLY_RATE_MAX - ZOOM_DOLLY_RATE_MIN));
};
const getBaseMouseWheelDollyRate = () => getZoomDollyRate(activeZoomSpeed);
const isHexColor = (value) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || "").trim());
const normalizeCustomDisplayColor = (value) => {
const raw = String(value || "").trim();
if (!isHexColor(raw)) return DEFAULT_CUSTOM_DISPLAY_COLOR;
if (raw.length === 4) {
const [, r, g, b] = raw;
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
}
return raw.toLowerCase();
};
const normalizeCustomDisplaySaturation = (value) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return DEFAULT_CUSTOM_DISPLAY_SATURATION;
return Math.min(200, Math.max(0, Math.round(numeric)));
};
const customDisplayHexToRgb01 = (value) => {
const hex = normalizeCustomDisplayColor(value).slice(1);
return [
Number.parseInt(hex.slice(0, 2), 16) / 255,
Number.parseInt(hex.slice(2, 4), 16) / 255,
Number.parseInt(hex.slice(4, 6), 16) / 255,
];
};
const applyRgbSaturation = (rgb, saturation) => {
const factor = normalizeCustomDisplaySaturation(saturation) / 100;
const gray = (rgb[0] * 0.2126) + (rgb[1] * 0.7152) + (rgb[2] * 0.0722);
return rgb.map((channel) => Math.min(1, Math.max(0, gray + ((channel - gray) * factor))));
};
const getCustomDisplayColorize = () => (
applyRgbSaturation(customDisplayHexToRgb01(activeCustomDisplayColor), activeCustomDisplaySaturation)
);
const configureCameraPanMap = (control) => {
const input = viewer?.scene?.input;
if (!control || !input) return;
if (control.MOUSE_PAN === undefined || control.MOUSE_ROTATE === undefined || control.MOUSE_DOLLY === undefined) return;
control.panRightClick = true;
control.keyMap = {
...(control.keyMap || {}),
[control.MOUSE_PAN]: [
[input.MOUSE_LEFT_BUTTON, input.KEY_SHIFT],
input.MOUSE_MIDDLE_BUTTON,
input.MOUSE_RIGHT_BUTTON,
],
[control.MOUSE_ROTATE]: [input.MOUSE_LEFT_BUTTON],
[control.MOUSE_DOLLY]: [],
};
};
const configureCameraControl = () => {
const control = viewer?.cameraControl;
if (!control) return;
activeZoomSpeed = normalizeZoomSpeed(activeZoomSpeed);
configureCameraPanMap(control);
control.followPointer = true;
control.doublePickFlyTo = false;
control.smartPivot = true;
control.zoomOnMouseWheel = true;
control.keyboardDollyRate = CAMERA_CONTROL_SETTINGS.keyboardDollyRate;
control.mouseWheelDollyRate = getBaseMouseWheelDollyRate();
control.dollyInertia = CAMERA_CONTROL_SETTINGS.dollyInertia;
control.dollyMinSpeed = CAMERA_CONTROL_SETTINGS.dollyMinSpeed;
control.dollyProximityThreshold = CAMERA_CONTROL_SETTINGS.dollyProximityThreshold;
};
const setZoomSpeed = (value, { announce = false } = {}) => {
activeZoomSpeed = normalizeZoomSpeed(value);
zoomSpeedSlider?.setValue?.(activeZoomSpeed);
configureCameraControl();
if (announce) {
setStatus(`Скорость зума: ${activeZoomSpeed}%`);
}
};
const ensureCameraControlActive = () => {
if (!viewer || !viewer.cameraControl) return;
configureCameraControl();
if (viewer.cameraControl.enabled === false) {
viewer.cameraControl.enabled = true;
}
if (viewer.cameraControl.active === false) {
viewer.cameraControl.active = true;
}
};
const resetCameraControl = () => {
if (!viewer?.cameraControl) return;
// Toggle active to drop any stuck drag state without adding extra loops
viewer.cameraControl.active = false;
viewer.cameraControl.active = true;
};
const rearmCameraControl = () => {
ensureCameraControlActive();
resetCameraControl();
};
const focusCanvasAndControls = () => {
rearmCameraControl();
if (mainCanvas?.focus) {
mainCanvas.focus({ preventScroll: true });
}
};
const setToolbarButtonActive = (btn, active) => {
if (!btn) return;
btn.classList.toggle("active", !!active);
};
const updateProjectionButton = () => {
const mode = viewer?.camera?.projection || "perspective";
if (!projectionToggle) return;
if (mode === "ortho") {
projectionToggle.textContent = "P";
setToolbarButtonActive(projectionToggle, true);
} else {
projectionToggle.textContent = "A";
setToolbarButtonActive(projectionToggle, false);
}
};
const normalizeDisplayMode = (mode) => (
DISPLAY_MODE_OPTIONS.some((option) => option.value === mode) ? mode : "source"
);
const normalizeLightingMode = (mode) => (
LIGHTING_MODE_OPTIONS.some((option) => option.value === mode) ? mode : "technical"
);
const normalizeNavigationAxis = (axis) => (
NAVIGATION_AXIS_OPTIONS.some((option) => option.value === axis) ? axis : "auto"
);
const setCustomDisplaySettings = ({ color, saturation } = {}) => {
if (color !== undefined) {
activeCustomDisplayColor = normalizeCustomDisplayColor(color);
customDisplayColorControl?.setValue?.(activeCustomDisplayColor);
}
if (saturation !== undefined) {
activeCustomDisplaySaturation = normalizeCustomDisplaySaturation(saturation);
customDisplaySaturationSlider?.setValue?.(activeCustomDisplaySaturation);
}
};
const setCustomDisplayControlsState = () => {
const isCustom = normalizeDisplayMode(activeDisplayMode) === "custom";
customDisplayColorRow?.classList.toggle("hidden", !isCustom);
customDisplaySaturationControl?.classList.toggle("hidden", !isCustom);
customDisplayColorControl?.setValue?.(activeCustomDisplayColor);
customDisplaySaturationSlider?.setValue?.(activeCustomDisplaySaturation);
};
const setDisplayModeControlState = () => {
activeDisplayMode = normalizeDisplayMode(activeDisplayMode);
displayModeSelect?.setValue(activeDisplayMode);
setCustomDisplayControlsState();
};
const setLightingModeControlState = () => {
activeLightingMode = normalizeLightingMode(activeLightingMode);
lightingModeSelect?.setValue(activeLightingMode);
};
const setNavigationAxisControlState = () => {
activeNavigationAxis = normalizeNavigationAxis(activeNavigationAxis);
navigationAxisSelect?.setValue(activeNavigationAxis);
};
const normalizeModelSettingsSrc = (src) => {
if (!src || typeof src !== "string") return "";
let value = src.trim();
if (!value) return "";
try {
const parsed = new URL(value, window.location.href);
if (parsed.origin === window.location.origin || /^https?:/i.test(value)) {
value = parsed.pathname;
}
} catch (_) {
// keep raw value
}
value = value.replace(/^\/+/, "");
return value.startsWith("uploads/") ? value : "";
};
const normalizeCommentSourceSrc = (src) => {
if (!src || typeof src !== "string") return "";
let value = src.trim();
if (!value) return "";
try {
const parsed = new URL(value, window.location.href);
if (parsed.origin === window.location.origin) {
value = `${parsed.pathname}${parsed.search || ""}`;
} else if (/^https?:/i.test(value)) {
value = parsed.href;
}
} catch (_) {
// keep raw value
}
return value.replace(/^\/+/, "");
};
const getModelSettingsSrc = (entry) => {
const meta = entry?.meta || {};
const candidates = [
meta.settingsSrc,
meta.sourceSrc,
meta.originalSrc,
meta.conversion?.sourceSrc,
meta.manifest?.sourceSrc,
meta.artifactSrc,
entry?.src,
];
for (const candidate of candidates) {
const normalized = normalizeModelSettingsSrc(candidate);
if (normalized) return normalized;
}
return "";
};
const getPrimaryModelEntry = () => (
activeModels.find((entry) => entry?.model?.visible !== false) || activeModels[0] || null
);
const hasModelSettingsTarget = () => !!getModelSettingsSrc(getPrimaryModelEntry());
const isGuestMode = () => viewerMode === "guest";
const isGuestToolbarAction = (action) => GUEST_TOOLBAR_ACTIONS.has(action);
const getShareTokenFromPath = () => {
const match = window.location.pathname.match(/^\/share\/([^/?#]+)/);
return match ? decodeURIComponent(match[1]) : null;
};
const getCurrentReturnPath = () => `${window.location.pathname}${window.location.search || ""}${window.location.hash || ""}`;
const getShareTopLevelPromotionUrl = () => {
const promoteUrl = startupSharePayload?.optionalPromoteUrl || startupSharePayload?.promoteUrl;
return typeof promoteUrl === "string" && promoteUrl.trim() ? promoteUrl : "";
};
const buildLauncherOptionalServiceUrl = (nextPath = getCurrentReturnPath(), fallbackPath = nextPath) => {
const launchUrl = new URL("/api/services/bim-viewer/optional-launch", getLauncherUrl());
launchUrl.searchParams.set("returnTo", nextPath);
launchUrl.searchParams.set("fallbackTo", fallbackPath);
return launchUrl.toString();
};
const hasShareGuestAuthMarker = () => {
try {
return new URLSearchParams(window.location.search || "").get(SHARE_AUTH_GUEST_PARAM) === "1";
} catch (_) {
return false;
}
};
const clearShareGuestAuthMarker = () => {
if (!hasShareGuestAuthMarker()) return;
try {
const url = new URL(window.location.href);
url.searchParams.delete(SHARE_AUTH_GUEST_PARAM);
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
} catch (_) {
// keep current URL if the History API is unavailable
}
};
const clearShareStartupPending = () => {
if (document.documentElement?.dataset?.shareStartup === "pending") {
delete document.documentElement.dataset.shareStartup;
}
};
const isNodeDCSessionLogoutEvent = (value) => (
value &&
typeof value === "object" &&
value.type === NODEDC_SESSION_LOGOUT_EVENT_TYPE &&
typeof value.id === "string"
);
const isNodeDCSessionLoginEvent = (value) => (
value &&
typeof value === "object" &&
value.type === NODEDC_SESSION_LOGIN_EVENT_TYPE &&
typeof value.id === "string"
);
let nodeDCSessionSyncCleanup = null;
let nodeDCLogoutHandling = false;
let nodeDCLoginHandling = false;
let nodeDCSessionWatchTimer = null;
let nodeDCSessionWatchInFlight = false;
let nodeDCLoginSyncPrimed = false;
let nodeDCLoginSyncEventId = "";
const handleNodeDCSessionLogout = async () => {
if (nodeDCLogoutHandling) return;
nodeDCLogoutHandling = true;
const logoutRequest = fetch("/logout", {
method: "GET",
credentials: "include",
keepalive: true,
cache: "no-store",
})
.catch(() => undefined);
runtimeSessionInfo = {
...runtimeSessionInfo,
authenticated: false,
canShare: false,
user: null,
};
if (startupSharePayload) {
startupSharePayload = {
...startupSharePayload,
mode: "guest",
readonly: true,
authenticated: false,
canShare: false,
user: null,
};
}
if (getShareTokenFromPath()) {
viewerMode = "guest";
inspectorCollapsed = true;
applyRuntimeMode();
updatePanelsVisibility();
nodeDCLogoutHandling = false;
return;
}
await Promise.race([
logoutRequest,
new Promise((resolve) => window.setTimeout(resolve, 500)),
]);
const loginUrl = new URL("/auth/login", getLauncherUrl());
window.location.replace(loginUrl.toString());
};
const handleNodeDCSessionLogin = () => {
if (nodeDCLoginHandling || runtimeSessionInfo.authenticated === true) return;
nodeDCLoginHandling = true;
const shareToken = getShareTokenFromPath();
if (shareToken) {
const promotionUrl = getShareTopLevelPromotionUrl() ||
buildLauncherOptionalServiceUrl(
`/share/${encodeURIComponent(shareToken)}`,
`/share/${encodeURIComponent(shareToken)}?${SHARE_AUTH_GUEST_PARAM}=1`
);
window.location.assign(promotionUrl);
return;
}
window.location.assign(buildLauncherOptionalServiceUrl());
};
const setupNodeDCSessionSync = () => {
if (nodeDCSessionSyncCleanup || typeof document === "undefined") return;
let launcherOrigin = "";
try {
launcherOrigin = new URL(getLauncherUrl()).origin;
} catch (_) {
return;
}
const iframe = document.createElement("iframe");
iframe.src = new URL("/auth/session-sync", launcherOrigin).toString();
iframe.title = "NODE.DC session sync";
iframe.tabIndex = -1;
iframe.setAttribute("aria-hidden", "true");
iframe.style.cssText = "position:fixed;width:0;height:0;opacity:0;pointer-events:none;border:0;";
document.body.appendChild(iframe);
const handleMessage = (event) => {
if (event.origin !== launcherOrigin) return;
if (isNodeDCSessionLogoutEvent(event.data)) {
void handleNodeDCSessionLogout();
return;
}
if (isNodeDCSessionLoginEvent(event.data)) {
handleNodeDCSessionLogin();
}
};
window.addEventListener("message", handleMessage);
nodeDCSessionSyncCleanup = () => {
window.removeEventListener("message", handleMessage);
iframe.remove();
nodeDCSessionSyncCleanup = null;
};
};
const scheduleShareSessionPromotion = (shareToken) => {
const silentPromoteUrl = startupSharePayload?.silentPromoteUrl;
if (
!shareToken ||
hasShareGuestAuthMarker() ||
viewerMode !== "guest" ||
runtimeSessionInfo.authenticated ||
typeof silentPromoteUrl !== "string" ||
!silentPromoteUrl.trim()
) {
return;
}
let storageKey = "";
try {
storageKey = `bimdc-share-promote:${shareToken}`;
const lastAttempt = Number(sessionStorage.getItem(storageKey) || 0);
if (Number.isFinite(lastAttempt) && Date.now() - lastAttempt < SHARE_PROMOTION_RETRY_MS) {
return;
}
sessionStorage.setItem(storageKey, String(Date.now()));
} catch (_) {
storageKey = "";
}
const iframe = document.createElement("iframe");
iframe.title = "BIM share auth promotion";
iframe.tabIndex = -1;
iframe.setAttribute("aria-hidden", "true");
iframe.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:-10000px;border:0;opacity:0;pointer-events:none;";
let done = false;
let timeoutId = null;
const cleanup = () => {
window.removeEventListener("message", onMessage);
if (timeoutId) window.clearTimeout(timeoutId);
iframe.remove();
};
const onMessage = (event) => {
if (event.origin !== window.location.origin) return;
const payload = event.data || {};
if (payload.type !== "nodedc:bim-share-promoted") return;
done = true;
cleanup();
if (payload.promoted === false) return;
window.location.replace(payload.nextPath || getCurrentReturnPath());
};
window.addEventListener("message", onMessage);
timeoutId = window.setTimeout(() => {
if (done) return;
cleanup();
}, SHARE_PROMOTION_TIMEOUT_MS);
iframe.src = silentPromoteUrl;
document.body.appendChild(iframe);
};
const getLoginSyncEventId = (sessionPayload) => (
typeof sessionPayload?.loginSyncEventId === "string" ? sessionPayload.loginSyncEventId : ""
);
const applyLiveSessionPayload = async (sessionPayload, { reason = "watch" } = {}) => {
const wasAuthenticated = runtimeSessionInfo.authenticated === true;
const isAuthenticated = sessionPayload?.authenticated === true;
const loginSyncEventId = getLoginSyncEventId(sessionPayload);
const hasNewLoginSyncEvent =
nodeDCLoginSyncPrimed &&
!isAuthenticated &&
!!loginSyncEventId &&
loginSyncEventId !== nodeDCLoginSyncEventId;
nodeDCLoginSyncPrimed = true;
if (loginSyncEventId) {
nodeDCLoginSyncEventId = loginSyncEventId;
}
runtimeSessionInfo = {
...runtimeSessionInfo,
...(sessionPayload || {}),
authenticated: isAuthenticated,
canShare: sessionPayload?.canShare === true,
user: sessionPayload?.user || null,
};
if (!isAuthenticated) {
if (wasAuthenticated) {
await handleNodeDCSessionLogout();
return;
}
if (hasNewLoginSyncEvent) {
handleNodeDCSessionLogin();
return;
}
const shareToken = getShareTokenFromPath();
if (shareToken && viewerMode === "guest") {
scheduleShareSessionPromotion(shareToken);
}
return;
}
if (getShareTokenFromPath() && viewerMode === "guest") {
viewerMode = "standard";
}
applyRuntimeMode();
updatePanelsVisibility();
if (reason === "focus" || reason === "visibility") {
loadProjectsList?.().catch(() => {});
}
};
const refreshNodeDCSessionStatus = async (options = {}) => {
if (nodeDCSessionWatchInFlight || nodeDCLogoutHandling) return;
nodeDCSessionWatchInFlight = true;
try {
const response = await fetch(AUTH_SESSION_API, {
credentials: "include",
cache: "no-store",
headers: { "Accept": "application/json" },
});
if (response.ok) {
await applyLiveSessionPayload(await response.json(), options);
}
} catch (_) {
// A transient network failure must not log the user out client-side.
} finally {
nodeDCSessionWatchInFlight = false;
}
};
const setupNodeDCSessionWatcher = () => {
if (nodeDCSessionWatchTimer || typeof window === "undefined") return;
nodeDCSessionWatchTimer = window.setInterval(() => {
void refreshNodeDCSessionStatus({ reason: "interval" });
}, SESSION_STATUS_WATCH_INTERVAL_MS);
window.addEventListener("focus", () => {
void refreshNodeDCSessionStatus({ reason: "focus" });
const shareToken = getShareTokenFromPath();
if (shareToken && viewerMode === "guest") {
scheduleShareSessionPromotion(shareToken);
}
});
window.addEventListener("pageshow", () => {
void refreshNodeDCSessionStatus({ reason: "pageshow" });
});
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") return;
void refreshNodeDCSessionStatus({ reason: "visibility" });
const shareToken = getShareTokenFromPath();
if (shareToken && viewerMode === "guest") {
scheduleShareSessionPromotion(shareToken);
}
});
};
const COMMENT_TARGET_ICON = `
`;
const formatDateTime = (value) => {
if (!value) return "";
try {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
} catch (_) {
return String(value);
}
};
const setCommentModalError = (message = "") => {
if (!commentModalError) return;
const text = String(message || "").trim();
commentModalError.textContent = text;
commentModalError.hidden = !text;
};
const setCommentModalSubtitle = (text = "") => {
if (!commentModalSubtitle) return;
const value = String(text || "").trim();
commentModalSubtitle.textContent = value;
commentModalSubtitle.hidden = !value;
};
const bringFloatingWindowToFront = (id) => {
if (!id) return;
floatingWindowOrder = [...floatingWindowOrder.filter((item) => item !== id), id];
floatingWindowOrder.forEach((item, index) => {
const element = floatingWindowElements.get(item);
if (element) element.style.zIndex = `${WORKSPACE_WINDOW_Z_BASE + index * WORKSPACE_WINDOW_Z_STEP}`;
});
};
const registerFloatingWindowElement = (id, element) => {
if (!id || !element) return;
floatingWindowElements.set(id, element);
if (!floatingWindowOrder.includes(id)) floatingWindowOrder.push(id);
element.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
bringFloatingWindowToFront(id);
}, true);
bringFloatingWindowToFront(id);
};
const extractUploadIdentityFromSrc = (src) => {
if (!src || typeof src !== "string") return {};
const match = src.match(/(?:^|\/)uploads\/([^/]+)\/([^/?#]+)/);
return match ? { projectId: match[1], assetId: match[2] } : {};
};
const getFallbackCommentSourceSrc = (entry) => {
const params = new URLSearchParams(window.location.search);
const querySrc = normalizeCommentSourceSrc(params.get("src") || params.get("url") || "");
if (querySrc) return querySrc;
const modelId = entry?.model?.id || entry?.id || "model";
if (currentProjectMeta?.id) {
return `project:${currentProjectMeta.id}:model:${modelId}`;
}
return `viewer:${window.location.pathname}${window.location.search || ""}:model:${modelId}`;
};
const getCommentSourceForEntry = (entry = getPrimaryModelEntry()) => {
if (!entry) return null;
const meta = entry.meta || {};
const src = getModelSettingsSrc(entry) ||
normalizeCommentSourceSrc(meta.sourceSrc) ||
normalizeCommentSourceSrc(meta.artifactSrc) ||
normalizeCommentSourceSrc(meta.settingsSrc) ||
normalizeCommentSourceSrc(entry.src) ||
getFallbackCommentSourceSrc(entry);
const uploadIdentity = extractUploadIdentityFromSrc(src);
return {
src,
sourceSrc: src,
settingsSrc: normalizeModelSettingsSrc(meta.settingsSrc) || src,
projectId: meta.projectId || uploadIdentity.projectId || currentProjectMeta?.id || null,
assetId: meta.assetId || uploadIdentity.assetId || null,
versionId: meta.versionId || null,
};
};
const sourceParamsForCommentSource = (source) => {
const params = new URLSearchParams();
if (!source) return params;
if (source.projectId) params.set("projectId", source.projectId);
if (source.assetId) params.set("assetId", source.assetId);
if (source.src) params.set("src", source.src);
if (source.sourceSrc) params.set("sourceSrc", source.sourceSrc);
if (source.settingsSrc) params.set("settingsSrc", source.settingsSrc);
return params;
};
const sourceCacheKey = (source) => {
const params = sourceParamsForCommentSource(source);
return params.toString();
};
const mergeCommentRecords = (items) => {
const byId = new Map(commentRecords.map((comment) => [comment.id, comment]));
(items || []).forEach((comment) => {
if (comment?.id) byId.set(comment.id, comment);
});
commentRecords = [...byId.values()]
.filter((comment) => !comment.deletedAt)
.sort((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
};
const loadCommentsForEntry = async (entry, { force = false } = {}) => {
if (!entry || isGuestMode()) return [];
const source = getCommentSourceForEntry(entry);
const key = sourceCacheKey(source);
if (!key) return [];
if (!force && commentSourcesLoaded.has(key)) return commentRecords;
const params = sourceParamsForCommentSource(source);
const payload = await fetchJSON(`${COMMENTS_API}?${params.toString()}`);
commentSourcesLoaded.add(key);
mergeCommentRecords(payload?.comments || []);
renderCommentsPanel();
renderCommentTargets();
return payload?.comments || [];
};
const loadCommentsForActiveModels = async ({ force = false } = {}) => {
if (isGuestMode()) return;
const entries = activeModels.length ? [...activeModels] : [getPrimaryModelEntry()].filter(Boolean);
for (const entry of entries) {
await loadCommentsForEntry(entry, { force }).catch((err) => {
console.warn("comments load failed", err);
});
}
};
const getCommentDisplayBody = (comment) => {
if (comment?.body) return comment.body;
const lastMessage = Array.isArray(comment?.messages) ? comment.messages[comment.messages.length - 1] : null;
return lastMessage?.body || "";
};
const syncCommentListActiveState = () => {
if (!commentsList) return;
commentsList.querySelectorAll(".comment-list-item").forEach((item) => {
const commentId = item.dataset.commentId;
item.classList.toggle("active", activeCommentId === commentId || hoverCommentId === commentId);
});
};
const renderCommentsPanel = () => {
if (!commentsList) return;
commentsList.replaceChildren();
if (!commentsModeActive) return;
if (!commentRecords.length) {
const empty = document.createElement("div");
empty.className = "comments-empty";
empty.textContent = "Комментариев пока нет";
commentsList.appendChild(empty);
return;
}
commentRecords.forEach((comment) => {
const item = document.createElement("button");
item.type = "button";
item.className = "comment-list-item";
item.classList.toggle("active", activeCommentId === comment.id || hoverCommentId === comment.id);
item.dataset.commentId = comment.id;
const title = document.createElement("div");
title.className = "comment-list-title";
title.textContent = comment.title || "Комментарий";
const body = document.createElement("div");
body.className = "comment-list-body";
body.textContent = getCommentDisplayBody(comment) || "Без описания";
const meta = document.createElement("div");
meta.className = "comment-list-meta";
const created = formatDateTime(comment.createdAt);
const updated = formatDateTime(comment.updatedAt);
meta.textContent = updated && updated !== created ? `Создан: ${created} · Изменён: ${updated}` : `Создан: ${created}`;
item.append(title, body, meta);
item.addEventListener("mouseenter", () => {
hoverCommentId = comment.id;
syncCommentListActiveState();
renderCommentTargets();
});
item.addEventListener("mouseleave", () => {
if (hoverCommentId === comment.id) hoverCommentId = null;
syncCommentListActiveState();
renderCommentTargets();
});
item.addEventListener("click", () => openCommentModalForComment(comment.id));
commentsList.appendChild(item);
});
};
const commentTargetsForRender = () => {
const targets = [...commentRecords];
if (commentModalMode === "create" && commentContextTarget?.worldPos) {
targets.push({
id: "__draft_comment__",
title: "Новый комментарий",
worldPos: commentContextTarget.worldPos,
draft: true,
});
}
return targets;
};
const updateCommentTargetPositions = () => {
if (!viewer?.scene?.camera || !commentTargetsLayer || !commentsModeActive) return;
const canvas = viewer.scene?.canvas?.canvas || mainCanvas;
const rect = canvas?.getBoundingClientRect?.();
if (!rect) return;
commentTargetsLayer.querySelectorAll(".comment-target").forEach((targetEl) => {
const id = targetEl.dataset.commentId;
const record = commentTargetsForRender().find((comment) => comment.id === id);
const pos = record?.worldPos;
if (!Array.isArray(pos) || pos.length !== 3) {
targetEl.hidden = true;
return;
}
let canvasPos = null;
try {
canvasPos = viewer.scene.camera.projectWorldPos(pos);
} catch (_) {
canvasPos = null;
}
if (!canvasPos || !Number.isFinite(canvasPos[0]) || !Number.isFinite(canvasPos[1])) {
targetEl.hidden = true;
return;
}
targetEl.hidden = false;
targetEl.style.left = `${Math.round(rect.left + canvasPos[0])}px`;
targetEl.style.top = `${Math.round(rect.top + canvasPos[1])}px`;
});
};
const startCommentTargetLoop = () => {
if (commentTargetRaf) return;
const tick = () => {
commentTargetRaf = 0;
if (!commentsModeActive) return;
updateCommentTargetPositions();
commentTargetRaf = window.requestAnimationFrame(tick);
};
commentTargetRaf = window.requestAnimationFrame(tick);
};
const stopCommentTargetLoop = () => {
if (commentTargetRaf) {
window.cancelAnimationFrame(commentTargetRaf);
commentTargetRaf = 0;
}
};
const syncCommentTargetActiveState = () => {
if (!commentTargetsLayer) return;
commentTargetsLayer.querySelectorAll(".comment-target").forEach((targetEl) => {
const commentId = targetEl.dataset.commentId;
targetEl.classList.toggle(
"active",
commentId === "__draft_comment__" || activeCommentId === commentId || hoverCommentId === commentId
);
});
};
const renderCommentTargets = () => {
if (!commentTargetsLayer) return;
commentTargetsLayer.replaceChildren();
if (!commentsModeActive || isGuestMode()) {
commentTargetsLayer.classList.add("hidden");
stopCommentTargetLoop();
return;
}
const targets = commentTargetsForRender();
targets.forEach((comment) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "comment-target";
btn.classList.toggle("active", comment.draft || activeCommentId === comment.id || hoverCommentId === comment.id);
btn.dataset.commentId = comment.id;
btn.title = comment.title || "Комментарий";
btn.setAttribute("aria-label", comment.title || "Комментарий");
btn.innerHTML = COMMENT_TARGET_ICON;
btn.addEventListener("mouseenter", () => {
if (!comment.draft) hoverCommentId = comment.id;
syncCommentTargetActiveState();
syncCommentListActiveState();
});
btn.addEventListener("mouseleave", () => {
if (hoverCommentId === comment.id) hoverCommentId = null;
syncCommentTargetActiveState();
syncCommentListActiveState();
});
btn.addEventListener("click", (event) => {
event.stopPropagation();
if (!comment.draft) openCommentModalForComment(comment.id);
});
commentTargetsLayer.appendChild(btn);
});
commentTargetsLayer.classList.toggle("hidden", targets.length === 0);
updateCommentTargetPositions();
startCommentTargetLoop();
};
const updateCommentsVisibility = () => {
const visible = commentsModeActive && !isGuestMode() && activeModels.length > 0;
setToolbarButtonActive(commentsButton, visible);
if (commentsPanel) {
commentsPanel.classList.toggle("hidden", !visible || commentsCollapsed);
}
if (commentsRestoreTray) {
commentsRestoreTray.hidden = !visible || !commentsCollapsed;
}
if (visible) {
renderCommentsPanel();
renderCommentTargets();
loadCommentsForActiveModels().catch(() => {});
} else {
commentsPanel?.classList.add("hidden");
if (commentsRestoreTray) commentsRestoreTray.hidden = true;
commentTargetsLayer?.classList.add("hidden");
hideCommentContextMenu();
stopCommentTargetLoop();
}
};
const setCommentsCollapsed = (collapsed) => {
commentsCollapsed = !!collapsed;
updateCommentsVisibility();
};
const toggleCommentsMode = async () => {
if (isGuestMode()) return;
if (!activeModels.length) {
setStatus("Сначала загрузите модель");
return;
}
commentsModeActive = !commentsModeActive;
if (commentsModeActive) {
commentsCollapsed = false;
bringFloatingWindowToFront("comments");
await loadCommentsForActiveModels({ force: true }).catch(() => {});
} else {
activeCommentId = null;
hoverCommentId = null;
}
updateCommentsVisibility();
};
const hideCommentContextMenu = () => {
commentContextMenu?.classList.add("hidden");
};
const clampMenuPosition = (x, y, menu) => {
const width = menu?.offsetWidth || 220;
const height = menu?.offsetHeight || 54;
return {
x: Math.max(8, Math.min(window.innerWidth - width - 8, x)),
y: Math.max(8, Math.min(window.innerHeight - height - 8, y)),
};
};
const getCanvasPointFromEvent = (event) => {
const canvas = viewer?.scene?.canvas?.canvas || mainCanvas;
const rect = canvas?.getBoundingClientRect?.();
if (!rect) return null;
return {
canvasPos: [event.clientX - rect.left, event.clientY - rect.top],
pagePos: [event.clientX, event.clientY],
};
};
const getDollyFactorForDistance = (distance) => {
const numeric = Number(distance);
if (!Number.isFinite(numeric) || numeric <= 0) return 1;
return Math.max(
CAMERA_CONTROL_SETTINGS.dollyMinSpeed,
numeric / CAMERA_CONTROL_SETTINGS.dollyProximityThreshold,
);
};
const getDollyFactorForWorldPos = (worldPos) => {
const eye = viewer?.camera?.eye || viewer?.scene?.camera?.eye;
if (!eye || !worldPos || worldPos.length < 3) return null;
const distance = Math.hypot(
Number(worldPos[0]) - Number(eye[0]),
Number(worldPos[1]) - Number(eye[1]),
Number(worldPos[2]) - Number(eye[2]),
);
return Number.isFinite(distance) ? getDollyFactorForDistance(distance) : null;
};
const getCameraFocusDollyFactor = () => {
const camera = viewer?.camera || viewer?.scene?.camera;
const directDistance = Number(camera?.eyeLookDist);
if (Number.isFinite(directDistance) && directDistance > 0) {
return getDollyFactorForDistance(directDistance);
}
const eye = camera?.eye;
const look = camera?.look;
if (!eye || !look) return 1;
const distance = Math.hypot(
Number(look[0]) - Number(eye[0]),
Number(look[1]) - Number(eye[1]),
Number(look[2]) - Number(eye[2]),
);
return Number.isFinite(distance) ? getDollyFactorForDistance(distance) : 1;
};
const rememberSurfaceDollyFactor = (hit) => {
const factor = getDollyFactorForWorldPos(hit?.worldPos);
if (factor === null) return;
lastSurfaceDollyFactor = factor;
lastSurfaceDollyFactorAt = window.performance?.now?.() || Date.now();
};
const getEmptyWheelDollyFactor = () => {
const focusFactor = getCameraFocusDollyFactor();
const now = window.performance?.now?.() || Date.now();
const hasFreshSurfaceFactor = (
lastSurfaceDollyFactor !== null &&
now - lastSurfaceDollyFactorAt <= LAST_SURFACE_DOLLY_FACTOR_TTL
);
return hasFreshSurfaceFactor ? Math.min(focusFactor, lastSurfaceDollyFactor) : focusFactor;
};
const restorePointerNavigation = () => {
if (emptyWheelNavigationRestoreTimer) {
window.clearTimeout(emptyWheelNavigationRestoreTimer);
emptyWheelNavigationRestoreTimer = null;
}
const control = viewer?.cameraControl;
if (!control) return;
control.followPointer = true;
control.smartPivot = true;
control.mouseWheelDollyRate = getBaseMouseWheelDollyRate();
};
const schedulePointerNavigationRestore = () => {
if (emptyWheelNavigationRestoreTimer) {
window.clearTimeout(emptyWheelNavigationRestoreTimer);
}
emptyWheelNavigationRestoreTimer = window.setTimeout(() => {
emptyWheelNavigationRestoreTimer = null;
restorePointerNavigation();
}, 80);
};
const getWheelSurfaceHit = (event) => {
if (!activeModels.length || !viewer?.scene?.pick) return true;
const point = getCanvasPointFromEvent(event);
if (!point) return true;
try {
return viewer.scene.pick({
canvasPos: point.canvasPos,
pickSurface: true,
});
} catch (_) {
return true;
}
};
const guardEmptyWheelNavigation = (event) => {
const control = viewer?.cameraControl;
if (!control) return;
const hit = getWheelSurfaceHit(event);
if (hit === true || hit?.entity || hit?.worldPos) {
rememberSurfaceDollyFactor(hit === true ? null : hit);
restorePointerNavigation();
return;
}
control.followPointer = true;
control.smartPivot = true;
control.mouseWheelDollyRate = getBaseMouseWheelDollyRate() * getEmptyWheelDollyFactor();
schedulePointerNavigationRestore();
};
const getWorldPosFromPick = (hit) => {
const direct = hit?.worldPos || hit?.worldPosition || hit?.position || hit?.origin;
if (direct && direct.length >= 3) {
return [Number(direct[0]), Number(direct[1]), Number(direct[2])].every(Number.isFinite)
? [Number(direct[0]), Number(direct[1]), Number(direct[2])]
: null;
}
const entity = hit?.entity;
const aabb = entity?.aabb || entity?.model?.aabb || null;
const aabbArray = Array.from(aabb || []);
return aabbArray.length === 6 ? centerFromAABB(aabbArray) : null;
};
const buildCommentTargetFromEvent = (event) => {
if (isGuestMode() || !viewer?.scene) return null;
const point = getCanvasPointFromEvent(event);
if (!point) return null;
const hit = viewer.scene.pick({
canvasPos: point.canvasPos,
pickSurface: true,
});
if (!hit) return null;
const entity = hit.entity || null;
const modelId = entity?.model?.id || entity?.modelId || null;
const objectId = entity?.id || entity?.objectId || null;
const entry = activeModels.find((item) => item?.model?.id === modelId) || getPrimaryModelEntry();
const source = getCommentSourceForEntry(entry);
const worldPos = getWorldPosFromPick(hit);
if (!source || !worldPos) return null;
return {
...source,
modelId,
objectId,
worldPos,
canvasPos: point.canvasPos,
pagePos: point.pagePos,
};
};
const showCommentContextMenu = (target, x, y) => {
if (!commentContextMenu || !target) return;
commentContextTarget = target;
commentContextMenu.classList.remove("hidden");
const pos = clampMenuPosition(x, y, commentContextMenu);
commentContextMenu.style.left = `${pos.x}px`;
commentContextMenu.style.top = `${pos.y}px`;
};
const openCommentContextMenuFromEvent = (event) => {
if (isGuestMode()) return false;
const target = buildCommentTargetFromEvent(event);
if (!target) {
hideCommentContextMenu();
return false;
}
event.preventDefault();
event.stopPropagation();
showCommentContextMenu(target, event.clientX, event.clientY);
return true;
};
const COMMENT_FILE_LIMIT_BYTES = 50 * 1024 * 1024;
const readAttachmentFiles = async (fileList) => {
const files = Array.from(fileList || [])
.filter((file) => file && file.size <= COMMENT_FILE_LIMIT_BYTES)
.slice(0, 8);
const skipped = Array.from(fileList || []).filter((file) => file && file.size > COMMENT_FILE_LIMIT_BYTES).length;
if (skipped) {
setCommentModalError(`Файлы больше 50 МБ не добавлены: ${skipped}`);
}
const readOne = (file) => new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve({
id: `att_${Date.now()}_${Math.random().toString(36).slice(2)}`,
name: file.name,
type: file.type,
size: file.size,
dataUrl: String(reader.result || ""),
});
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
const items = await Promise.all(files.map(readOne));
return items.filter(Boolean);
};
const formatFileSize = (value) => {
const size = Number(value);
if (!Number.isFinite(size) || size <= 0) return "";
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(size >= 10 * 1024 * 1024 ? 0 : 1)} МБ`;
if (size >= 1024) return `${Math.round(size / 1024)} КБ`;
return `${Math.round(size)} Б`;
};
const renderAttachmentGrid = (grid, attachments, onRemove) => {
if (!grid) return;
grid.replaceChildren();
(attachments || []).forEach((attachment, index) => {
const thumb = document.createElement("div");
thumb.className = "comment-attachment-thumb";
const isImage = String(attachment.type || "").startsWith("image/") || /^data:image\//i.test(String(attachment.dataUrl || ""));
if (isImage) {
const img = document.createElement("img");
img.src = attachment.dataUrl;
img.alt = attachment.name || "attachment";
thumb.appendChild(img);
} else {
const file = document.createElement("div");
file.className = "comment-attachment-file";
file.innerHTML = `
`;
const meta = document.createElement("div");
meta.className = "comment-attachment-file__meta";
const name = document.createElement("strong");
name.textContent = attachment.name || "Файл";
const size = document.createElement("span");
size.textContent = formatFileSize(attachment.size) || attachment.type || "Файл";
meta.append(name, size);
file.appendChild(meta);
thumb.appendChild(file);
}
const remove = document.createElement("button");
remove.type = "button";
remove.setAttribute("aria-label", "Удалить файл");
remove.textContent = "×";
remove.addEventListener("click", () => onRemove?.(index));
thumb.appendChild(remove);
grid.appendChild(thumb);
});
};
const renderThreadAttachments = (container, attachments) => {
if (!attachments?.length) return;
const grid = document.createElement("div");
grid.className = "comment-attachment-grid";
attachments.forEach((attachment) => {
const thumb = document.createElement("div");
thumb.className = "comment-attachment-thumb";
const isImage = String(attachment.type || "").startsWith("image/") || /^data:image\//i.test(String(attachment.dataUrl || ""));
if (isImage) {
const img = document.createElement("img");
img.src = attachment.dataUrl;
img.alt = attachment.name || "attachment";
thumb.appendChild(img);
} else {
const file = document.createElement("div");
file.className = "comment-attachment-file";
file.innerHTML = `
`;
const meta = document.createElement("div");
meta.className = "comment-attachment-file__meta";
const name = document.createElement("strong");
name.textContent = attachment.name || "Файл";
const size = document.createElement("span");
size.textContent = formatFileSize(attachment.size) || attachment.type || "Файл";
meta.append(name, size);
file.appendChild(meta);
thumb.appendChild(file);
}
grid.appendChild(thumb);
});
container.appendChild(grid);
};
const renderCommentThread = (comment) => {
if (!commentThreadList) return;
commentThreadList.replaceChildren();
const messages = Array.isArray(comment?.messages) ? comment.messages : [];
if (!messages.length) {
const empty = document.createElement("div");
empty.className = "comment-thread-empty";
empty.textContent = "Комментариев пока нет";
commentThreadList.appendChild(empty);
return;
}
messages.forEach((message) => {
const item = document.createElement("div");
item.className = "comment-thread-message";
const head = document.createElement("div");
head.className = "comment-thread-message__head";
const author = document.createElement("strong");
author.textContent = message?.createdBy?.name || message?.createdBy?.email || "Пользователь";
const date = document.createElement("span");
date.textContent = formatDateTime(message?.updatedAt || message?.createdAt);
head.append(author, date);
const body = document.createElement("div");
body.className = "comment-thread-message__body";
body.textContent = message?.body || "";
item.append(head, body);
renderThreadAttachments(item, message?.attachments || []);
commentThreadList.appendChild(item);
});
};
const resetCommentModalAttachments = () => {
commentDraftAttachments = [];
commentReplyAttachments = [];
commentDraftSubitems = [];
renderModalAttachmentInputs();
renderCommentSubitems();
};
const renderModalAttachmentInputs = () => {
renderAttachmentGrid(commentAttachmentGrid, commentDraftAttachments, (index) => {
commentDraftAttachments.splice(index, 1);
renderModalAttachmentInputs();
});
renderAttachmentGrid(commentReplyAttachmentGrid, commentReplyAttachments, (index) => {
commentReplyAttachments.splice(index, 1);
renderModalAttachmentInputs();
});
};
const setCommentModalExpanded = (expanded) => {
commentModalExpanded = !!expanded;
commentModal?.classList.toggle("is-expanded", commentModalExpanded);
if (commentModalExpandButton) {
commentModalExpandButton.setAttribute("aria-label", commentModalExpanded ? "Свернуть" : "Развернуть");
commentModalExpandButton.title = commentModalExpanded ? "Свернуть" : "Развернуть";
}
};
const hideCommentSubitemMenu = () => {
commentSubitemMenu?.classList.add("hidden");
};
const showCommentSubitemMenu = () => {
if (!commentSubitemMenu || !commentAddSubitemButton) return;
if (commentSubitemMenu.parentElement !== document.body) {
document.body.appendChild(commentSubitemMenu);
}
commentSubitemMenu.classList.remove("hidden");
const buttonRect = commentAddSubitemButton.getBoundingClientRect();
const width = commentSubitemMenu.offsetWidth || 276;
const x = Math.max(8, Math.min(window.innerWidth - width - 8, buttonRect.left));
const y = buttonRect.bottom + 8;
commentSubitemMenu.style.left = `${x}px`;
commentSubitemMenu.style.top = `${y}px`;
};
const toggleCommentSubitemMenu = () => {
if (!commentSubitemMenu) return;
if (commentSubitemMenu.classList.contains("hidden")) {
showCommentSubitemMenu();
} else {
hideCommentSubitemMenu();
}
};
const addCommentSubitem = (kind) => {
const nextKind = kind === "text" ? "text" : "checker";
commentDraftSubitems.push({
id: `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`,
kind: nextKind,
title: "",
body: "",
done: false,
});
renderCommentSubitems();
window.requestAnimationFrame(() => {
const selector = nextKind === "text"
? ".ops-comment-subitem[data-kind='text'] textarea"
: ".ops-comment-subitem[data-kind='checker'] input[type='text']";
const inputs = commentSubitemsList?.querySelectorAll?.(selector);
inputs?.[inputs.length - 1]?.focus?.();
});
};
const renderCommentSubitems = () => {
if (!commentSubitemsList || !commentSubitemsSection) return;
commentSubitemsList.replaceChildren();
const subitems = Array.isArray(commentDraftSubitems) ? commentDraftSubitems : [];
commentSubitemsSection.classList.toggle("hidden", !subitems.length);
subitems.forEach((subitem, index) => {
const kind = subitem.kind === "text" ? "text" : "checker";
const row = document.createElement(kind === "text" ? "div" : "label");
row.className = "ops-comment-subitem";
row.dataset.kind = kind;
let editor = null;
if (kind === "text") {
editor = document.createElement("textarea");
editor.rows = 3;
editor.value = subitem.body || subitem.title || "";
editor.placeholder = "Текстовый блок";
editor.addEventListener("input", () => {
commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, body: editor.value, title: editor.value };
});
} else {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = !!subitem.done;
checkbox.addEventListener("change", () => {
commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, done: checkbox.checked };
});
editor = document.createElement("input");
editor.type = "text";
editor.value = subitem.title || subitem.body || "";
editor.placeholder = "Новый чекер";
editor.addEventListener("input", () => {
commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, title: editor.value };
});
row.appendChild(checkbox);
}
const remove = document.createElement("button");
remove.type = "button";
remove.setAttribute("aria-label", "Удалить подэлемент");
remove.textContent = "×";
remove.addEventListener("click", () => {
commentDraftSubitems.splice(index, 1);
renderCommentSubitems();
});
row.append(editor, remove);
commentSubitemsList.appendChild(row);
});
};
const openCommentModalForTarget = () => {
if (!commentContextTarget || !commentModal) return;
hideCommentSubitemMenu();
commentsModeActive = true;
commentsCollapsed = false;
commentModalMode = "create";
commentModalCommentId = null;
activeCommentId = "__draft_comment__";
commentTitleInput && (commentTitleInput.value = "");
commentBodyInput && (commentBodyInput.value = "");
commentReplyInput && (commentReplyInput.value = "");
setCommentModalSubtitle();
if (commentModalEditedMeta) {
const [x, y, z] = commentContextTarget.worldPos || [];
commentModalEditedMeta.textContent = Number.isFinite(x)
? `Последнее редактирование: новая точка ${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)}`
: "Последнее редактирование: новый комментарий";
}
setCommentModalError("");
resetCommentModalAttachments();
renderCommentThread(null);
commentModalBackdrop?.classList.remove("hidden");
commentModal.classList.remove("hidden");
bringFloatingWindowToFront("commentModal");
updateCommentsVisibility();
commentTitleInput?.focus();
};
const openCommentModalForComment = (commentId) => {
const comment = commentRecords.find((item) => item.id === commentId);
if (!comment || !commentModal) return;
hideCommentSubitemMenu();
commentContextTarget = null;
commentModalMode = "edit";
commentModalCommentId = comment.id;
activeCommentId = comment.id;
commentTitleInput && (commentTitleInput.value = comment.title || "");
commentBodyInput && (commentBodyInput.value = comment.body || "");
commentReplyInput && (commentReplyInput.value = "");
setCommentModalSubtitle();
if (commentModalEditedMeta) {
commentModalEditedMeta.textContent = `Последнее редактирование: ${formatDateTime(comment.updatedAt || comment.createdAt)}`;
}
setCommentModalError("");
resetCommentModalAttachments();
commentDraftSubitems = Array.isArray(comment.subitems)
? comment.subitems.map((item) => ({ ...item }))
: [];
renderCommentSubitems();
renderCommentThread(comment);
commentModalBackdrop?.classList.remove("hidden");
commentModal.classList.remove("hidden");
commentsModeActive = true;
commentsCollapsed = false;
bringFloatingWindowToFront("commentModal");
updateCommentsVisibility();
commentTitleInput?.focus();
renderCommentsPanel();
renderCommentTargets();
};
const closeCommentModal = () => {
hideCommentSubitemMenu();
commentModalBackdrop?.classList.add("hidden");
commentModal?.classList.add("hidden");
setCommentModalExpanded(false);
if (commentModalMode === "create") {
commentContextTarget = null;
}
activeCommentId = null;
commentModalMode = "create";
commentModalCommentId = null;
setCommentModalError("");
renderCommentsPanel();
renderCommentTargets();
};
const normalizedSubitemsDraft = () => (
(commentDraftSubitems || [])
.map((item) => {
const kind = item.kind === "text" ? "text" : "checker";
const content = String(kind === "text" ? (item.body || item.title || "") : (item.title || item.body || "")).trim();
return {
id: item.id || `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`,
kind,
title: content,
body: kind === "text" ? content : "",
done: kind === "checker" && item.done === true,
};
})
.filter((item) => item.title || item.body)
);
const buildCommentPayload = ({ title, body, replyBody } = {}) => {
const finalBody = body || replyBody || "";
return {
...(commentContextTarget || {}),
title: title || (finalBody ? finalBody.slice(0, 80) : "Комментарий"),
body: finalBody,
attachments: [...commentDraftAttachments, ...commentReplyAttachments].slice(0, 8),
subitems: normalizedSubitemsDraft(),
};
};
const syncCommentModalAfterSave = (comment) => {
if (!comment) return;
commentModalMode = "edit";
commentModalCommentId = comment.id;
commentContextTarget = null;
activeCommentId = comment.id;
commentTitleInput && (commentTitleInput.value = comment.title || "");
commentBodyInput && (commentBodyInput.value = comment.body || "");
commentReplyInput && (commentReplyInput.value = "");
commentDraftAttachments = [];
commentReplyAttachments = [];
commentDraftSubitems = Array.isArray(comment.subitems)
? comment.subitems.map((item) => ({ ...item }))
: [];
setCommentModalSubtitle();
if (commentModalEditedMeta) {
commentModalEditedMeta.textContent = `Последнее редактирование: ${formatDateTime(comment.updatedAt || comment.createdAt)}`;
}
renderModalAttachmentInputs();
renderCommentSubitems();
renderCommentThread(comment);
};
const applyCommentModal = async ({ closeOnSave = false } = {}) => {
if (isGuestMode()) return;
if (commentSaveInFlight) return;
const title = commentTitleInput?.value?.trim() || "";
const body = commentBodyInput?.value?.trim() || "";
const replyBody = commentReplyInput?.value?.trim() || "";
setCommentModalError("");
if (commentModalMode === "create" && !commentContextTarget?.worldPos) {
setCommentModalError("Не выбрана точка комментария");
return;
}
commentSaveInFlight = true;
if (commentApplyButton) {
commentApplyButton.disabled = true;
commentApplyButton.textContent = "Сохранение...";
}
if (commentSaveButton) {
commentSaveButton.disabled = true;
commentSaveButton.textContent = "Сохранение...";
}
if (commentApplyInlineButton) {
commentApplyInlineButton.disabled = true;
}
try {
let payload = null;
if (commentModalMode === "create") {
payload = await fetchJSON(COMMENTS_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildCommentPayload({ title, body, replyBody })),
});
} else if (commentModalCommentId) {
const original = commentRecords.find((item) => item.id === commentModalCommentId);
const subitems = normalizedSubitemsDraft();
const messageAttachments = [...commentDraftAttachments, ...commentReplyAttachments].slice(0, 8);
const patchPayload = {};
if (!original || title !== (original.title || "")) patchPayload.title = title;
if (!original || body !== (original.body || "")) patchPayload.body = body;
if (JSON.stringify(subitems) !== JSON.stringify(original?.subitems || [])) patchPayload.subitems = subitems;
if (replyBody || messageAttachments.length) {
patchPayload.message = {
body: replyBody,
attachments: messageAttachments,
};
}
if (!Object.keys(patchPayload).length) {
if (closeOnSave) {
closeCommentModal();
} else {
setStatus("Изменений нет");
}
return;
}
payload = await fetchJSON(`${COMMENTS_API}/${encodeURIComponent(commentModalCommentId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patchPayload),
});
}
const saved = payload?.comment;
if (saved) {
mergeCommentRecords([saved]);
activeCommentId = saved.id;
commentContextTarget = null;
commentsModeActive = true;
commentsCollapsed = false;
syncCommentModalAfterSave(saved);
if (closeOnSave) {
closeCommentModal();
}
updateCommentsVisibility();
setStatus("Комментарий сохранён");
}
} catch (err) {
console.error(err);
const message = err.message || "Не удалось сохранить комментарий";
setCommentModalError(message);
setStatus(message);
} finally {
commentSaveInFlight = false;
if (commentApplyButton) {
commentApplyButton.disabled = false;
commentApplyButton.textContent = "Применить";
}
if (commentSaveButton) {
commentSaveButton.disabled = false;
commentSaveButton.textContent = "Сохранить";
}
if (commentApplyInlineButton) {
commentApplyInlineButton.disabled = false;
}
}
};
const setupDraggableFixedWindow = (element, handle) => {
if (!element || !handle) return;
let drag = null;
handle.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || event.target.closest("button,input,textarea")) return;
const rect = element.getBoundingClientRect();
drag = {
pointerId: event.pointerId,
dx: event.clientX - rect.left,
dy: event.clientY - rect.top,
};
element.style.left = `${rect.left}px`;
element.style.top = `${rect.top}px`;
element.style.right = "auto";
element.style.bottom = "auto";
element.style.transform = "none";
handle.setPointerCapture?.(event.pointerId);
event.preventDefault();
});
handle.addEventListener("pointermove", (event) => {
if (!drag || drag.pointerId !== event.pointerId) return;
const width = element.offsetWidth || 320;
const height = element.offsetHeight || 320;
const left = Math.max(8, Math.min(window.innerWidth - width - 8, event.clientX - drag.dx));
const top = Math.max(8, Math.min(window.innerHeight - Math.min(height, window.innerHeight - 16) - 8, event.clientY - drag.dy));
element.style.left = `${left}px`;
element.style.top = `${top}px`;
});
const endDrag = (event) => {
if (!drag || drag.pointerId !== event.pointerId) return;
handle.releasePointerCapture?.(event.pointerId);
drag = null;
};
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag);
};
const setupResizableFixedWindow = (element, { minWidth = 520, minHeight = 420, margin = 8 } = {}) => {
if (!element || element.dataset.resizableReady === "true") return;
element.dataset.resizableReady = "true";
const directions = ["n", "e", "s", "w", "ne", "nw", "se", "sw"];
let resize = null;
const fixCurrentRect = () => {
const rect = element.getBoundingClientRect();
element.style.left = `${rect.left}px`;
element.style.top = `${rect.top}px`;
element.style.right = "auto";
element.style.bottom = "auto";
element.style.width = `${rect.width}px`;
element.style.height = `${rect.height}px`;
element.style.maxHeight = "none";
element.style.transform = "none";
return rect;
};
const beginResize = (direction, pointerId, clientX, clientY) => {
bringFloatingWindowToFront(element.id === "commentModal" ? "commentModal" : element.id);
const rect = fixCurrentRect();
resize = {
pointerId,
direction,
startX: clientX,
startY: clientY,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
};
const updateResize = (clientX, clientY) => {
if (!resize) return;
const dx = clientX - resize.startX;
const dy = clientY - resize.startY;
const right = resize.left + resize.width;
const bottom = resize.top + resize.height;
let left = resize.left;
let top = resize.top;
let width = resize.width;
let height = resize.height;
if (resize.direction.includes("w")) {
left = Math.max(margin, Math.min(resize.left + dx, right - minWidth));
width = right - left;
}
if (resize.direction.includes("e")) {
width = Math.max(minWidth, resize.width + dx);
width = Math.min(width, window.innerWidth - margin - left);
}
if (resize.direction.includes("n")) {
top = Math.max(margin, Math.min(resize.top + dy, bottom - minHeight));
height = bottom - top;
}
if (resize.direction.includes("s")) {
height = Math.max(minHeight, resize.height + dy);
height = Math.min(height, window.innerHeight - margin - top);
}
element.style.left = `${Math.round(left)}px`;
element.style.top = `${Math.round(top)}px`;
element.style.width = `${Math.round(width)}px`;
element.style.height = `${Math.round(height)}px`;
};
directions.forEach((direction) => {
const handle = document.createElement("span");
handle.className = `window-resize-handle window-resize-handle--${direction}`;
handle.dataset.resizeDirection = direction;
handle.setAttribute("aria-hidden", "true");
element.appendChild(handle);
handle.addEventListener("pointerdown", (event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
if (element.classList.contains("is-expanded")) return;
event.preventDefault();
event.stopPropagation();
beginResize(direction, event.pointerId, event.clientX, event.clientY);
handle.setPointerCapture?.(event.pointerId);
});
handle.addEventListener("pointermove", (event) => {
if (!resize || resize.pointerId !== event.pointerId) return;
updateResize(event.clientX, event.clientY);
});
handle.addEventListener("mousedown", (event) => {
if (event.button !== 0 || element.classList.contains("is-expanded")) return;
event.preventDefault();
event.stopPropagation();
beginResize(direction, "mouse", event.clientX, event.clientY);
});
const endResize = (event) => {
if (!resize || resize.pointerId !== event.pointerId) return;
handle.releasePointerCapture?.(event.pointerId);
resize = null;
};
handle.addEventListener("pointerup", endResize);
handle.addEventListener("pointercancel", endResize);
});
window.addEventListener("mousemove", (event) => {
if (!resize || resize.pointerId !== "mouse") return;
updateResize(event.clientX, event.clientY);
});
window.addEventListener("mouseup", () => {
if (resize?.pointerId === "mouse") resize = null;
});
};
const bindFooterButtonAction = (button, handler) => {
if (!button || typeof handler !== "function") return;
button.addEventListener("click", handler);
button.addEventListener("pointerup", (event) => {
const isPrimaryPointer = event.pointerType !== "mouse" || event.button === 0;
if (!isPrimaryPointer || button.disabled) return;
event.preventDefault();
event.stopPropagation();
handler(event);
}, true);
};
const applyRuntimeMode = () => {
const guest = isGuestMode();
document.body.dataset.viewerMode = viewerMode;
if (bottomToolbar) {
bottomToolbar.classList.remove("hidden");
bottomToolbar.querySelectorAll(".tb-btn").forEach((btn) => {
const action = btn.dataset.action || "";
const guestHidden = guest && !isGuestToolbarAction(action);
btn.classList.toggle("hidden", guestHidden);
if (guestHidden) {
btn.disabled = true;
} else if (!guest && action !== "section" && action !== "comments" && action !== "share") {
btn.disabled = false;
}
});
}
if (settingsButton) settingsButton.disabled = guest;
if (templatesButton) templatesButton.disabled = guest;
if (measureButton && guest) measureButton.disabled = !measurePlugin || !measureControl || activeModels.length === 0;
if (projectionToggle && guest) projectionToggle.disabled = activeModels.length === 0;
if (commentsButton) {
commentsButton.classList.toggle("hidden", guest);
commentsButton.disabled = guest || activeModels.length === 0;
}
if (guest) {
closeSettingsPanel();
closeTemplatesPanel();
settingsPanel?.classList.add("hidden");
templatesPanel?.classList.add("hidden");
commentsModeActive = false;
commentsCollapsed = false;
commentContextTarget = null;
activeCommentId = null;
hoverCommentId = null;
commentsPanel?.classList.add("hidden");
if (commentsRestoreTray) commentsRestoreTray.hidden = true;
commentTargetsLayer?.classList.add("hidden");
hideCommentContextMenu();
stopCommentTargetLoop();
}
if (shareButton) {
const canShowShare = !guest && runtimeSessionInfo.canShare && activeModels.length > 0;
shareButton.classList.toggle("hidden", !canShowShare);
shareButton.disabled = !canShowShare;
}
if (dropZone) {
dropZone.style.pointerEvents = guest ? "none" : "";
}
};
const initializeRuntimeMode = async () => {
const shareToken = getShareTokenFromPath();
const shareGuestAuthMarker = hasShareGuestAuthMarker();
if (shareToken) {
viewerMode = "guest";
inspectorCollapsed = true;
try {
const response = await fetch(`${SHARES_API}/${encodeURIComponent(shareToken)}`, {
credentials: "include",
headers: { "Accept": "application/json" },
});
if (!response.ok) {
throw new Error(`Share link failed: HTTP ${response.status}`);
}
startupSharePayload = await response.json();
} catch (err) {
console.error(err);
showError("Ссылка на модель недоступна");
}
}
try {
const response = await fetch(AUTH_SESSION_API, {
credentials: "include",
headers: { "Accept": "application/json" },
});
if (response.ok) {
runtimeSessionInfo = await response.json();
}
} catch (err) {
console.warn("BIM session status unavailable", err);
}
if (shareToken) {
const authenticatedShare = runtimeSessionInfo.authenticated === true ||
startupSharePayload?.authenticated === true ||
startupSharePayload?.mode === "standard" ||
startupSharePayload?.readonly === false;
viewerMode = authenticatedShare ? "standard" : "guest";
}
applyRuntimeMode();
clearShareGuestAuthMarker();
if (shareToken && viewerMode === "guest" && !shareGuestAuthMarker) {
scheduleShareSessionPromotion(shareToken);
}
};
const getShareableModelPayload = () => {
const entry = getPrimaryModelEntry();
if (!entry) {
return null;
}
const selectedLodSrc = getSelectedLodViewerSrc(entry);
const src = normalizeModelSettingsSrc(selectedLodSrc || entry.meta?.artifactSrc || entry.src || entry.meta?.src);
const settingsSrc = getModelSettingsSrc(entry);
if (!src) {
return null;
}
const versionIdentity = getModelVersionIdentity(entry);
return {
assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || null,
projectId: entry.meta?.projectId || entry.meta?.conversion?.projectId || versionIdentity.projectId || null,
name: entry.name || entry.meta?.label || src.split("/").pop() || "model",
selectedLod: getCurrentLodIdForEntry(entry) || null,
settingsSrc: settingsSrc || src,
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 : [],
};
};
const closeShareModal = () => {
shareModal?.classList.add("hidden");
shareModalBackdrop?.classList.add("hidden");
if (shareError) shareError.textContent = "";
};
const openShareModal = async () => {
if (isGuestMode()) return;
const payload = getShareableModelPayload();
if (!payload) {
showError("Ссылку можно создать только для модели из BIM-хранилища");
return;
}
shareModal?.classList.remove("hidden");
shareModalBackdrop?.classList.remove("hidden");
if (shareLinkInput) shareLinkInput.value = "Генерируем ссылку...";
if (shareCopyButton) shareCopyButton.disabled = true;
if (shareError) shareError.textContent = "";
try {
const response = await fetch(SHARES_API, {
method: "POST",
credentials: "include",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => null);
if (!response.ok || !data?.url) {
throw new Error(data?.error || `Share create failed: HTTP ${response.status}`);
}
if (shareLinkInput) {
shareLinkInput.value = data.url;
shareLinkInput.select();
}
if (shareCopyButton) shareCopyButton.disabled = false;
} catch (err) {
console.error(err);
if (shareLinkInput) shareLinkInput.value = "";
if (shareError) shareError.textContent = err?.message || "Не удалось создать ссылку";
}
};
const copyShareLink = async () => {
const value = shareLinkInput?.value || "";
if (!value) return;
try {
await navigator.clipboard?.writeText(value);
} catch (_) {
shareLinkInput?.select();
document.execCommand?.("copy");
}
};
const getDisplayModePreset = (mode) => {
switch (mode) {
case "clay":
return { colorize: [0.76, 0.35, 0.25], opacity: 1, edges: false, xrayed: false };
case "white":
return { colorize: [1, 1, 1], opacity: 1, edges: false, xrayed: false };
case "custom":
return { colorize: getCustomDisplayColorize(), opacity: 1, edges: false, xrayed: false };
case "edges":
return { colorize: null, opacity: 1, edges: true, xrayed: false };
case "xray":
return { colorize: [0.72, 0.9, 1], opacity: 1, edges: true, xrayed: true };
case "source":
default:
return { colorize: null, opacity: 1, edges: "base", xrayed: false };
}
};
const getBaseEdgesForModel = (modelId) => {
const modelType = modelTypes[modelId];
if (modelType === "las") return false;
if (modelType === "gltf") return false;
return !!toggleEdges?.checked;
};
const isPointCloudModel = (modelId) => modelTypes[modelId] === "las";
const getPointCloudLayers = (model) => (
(model?.layerList || []).filter((layer) => (
layer?.primitive === "points" &&
layer?._state?.colorsBuf?.getData &&
layer?._state?.colorsBuf?.setData
))
);
const snapshotPointCloudColors = (modelId) => {
const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId];
if (!model || !isPointCloudModel(modelId) || pointCloudColorSnapshots.has(modelId)) return;
const snapshots = getPointCloudLayers(model)
.map((layer) => {
try {
const colors = layer._state.colorsBuf.getData();
return colors?.length ? { layer, colors, workingColors: null } : null;
} catch (err) {
console.warn("point cloud color snapshot failed", err);
return null;
}
})
.filter(Boolean);
if (snapshots.length) {
pointCloudColorSnapshots.set(modelId, snapshots);
}
};
const clearPointCloudEntityColorState = (targets) => {
targets.forEach((entity) => {
if (!entity) return;
try {
entity.xrayed = false;
entity.opacity = 1;
entity.edges = false;
(entity.meshes || []).forEach((mesh) => {
mesh._colorizing = false;
});
if (entity._colorizeUpdated && viewer?.scene?._objectColorizeUpdated) {
viewer.scene._objectColorizeUpdated(entity, false);
entity._colorizeUpdated = false;
}
} catch (err) {
console.warn("point cloud entity reset failed", err);
}
});
};
const writePointCloudColors = (modelId, colorize = null) => {
snapshotPointCloudColors(modelId);
const snapshots = pointCloudColorSnapshots.get(modelId) || [];
const color = colorize
? colorize.map((v) => Math.max(0, Math.min(255, Math.round(v * 255))))
: null;
snapshots.forEach((snapshot) => {
const colorsBuf = snapshot.layer?._state?.colorsBuf;
if (!colorsBuf?.setData) return;
try {
if (!color) {
colorsBuf.setData(snapshot.colors);
return;
}
const original = snapshot.colors;
const working = snapshot.workingColors?.length === original.length
? snapshot.workingColors
: new Uint8Array(original.length);
for (let i = 0; i < original.length; i += 4) {
working[i] = color[0];
working[i + 1] = color[1];
working[i + 2] = color[2];
working[i + 3] = original[i + 3];
}
snapshot.workingColors = working;
colorsBuf.setData(working);
} catch (err) {
console.warn("point cloud color restore failed", err);
}
});
};
const applyPointCloudDisplayMode = (modelId, preset, targets) => {
clearPointCloudEntityColorState(targets);
writePointCloudColors(modelId, preset.colorize);
viewer?.scene?.glRedraw?.();
};
const applyDisplayModeToModel = (modelId) => {
const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId];
if (!model) return;
const preset = getDisplayModePreset(normalizeDisplayMode(activeDisplayMode));
const objects = Object.values(model.objects || {});
const targets = objects.length ? objects : [model];
if (isPointCloudModel(modelId)) {
applyPointCloudDisplayMode(modelId, preset, targets);
return;
}
targets.forEach((entity) => {
if (!entity) return;
try {
entity.xrayed = !!preset.xrayed;
entity.colorize = preset.colorize;
entity.opacity = preset.opacity;
entity.edges = modelTypes[modelId] === "las"
? false
: (preset.edges === "base" ? getBaseEdgesForModel(modelId) : !!preset.edges);
} catch (err) {
console.warn("display mode update failed", err);
}
});
viewer?.scene?.glRedraw?.();
};
const applyDisplayMode = () => {
setDisplayModeControlState();
Object.keys(sceneModels).forEach((modelId) => applyDisplayModeToModel(modelId));
};
const createSceneLight = (type, cfg) => {
if (!viewer?.scene || !sdkRef) return null;
if (type === "ambient" && sdkRef.AmbientLight) {
return new sdkRef.AmbientLight(viewer.scene, cfg);
}
if (type === "dir" && sdkRef.DirLight) {
return new sdkRef.DirLight(viewer.scene, cfg);
}
return null;
};
const resetSceneLighting = () => {
if (!viewer?.scene) return;
viewer.scene.clearLights?.();
};
const applyLightingMode = ({ announce = false } = {}) => {
activeLightingMode = normalizeLightingMode(activeLightingMode);
setLightingModeControlState();
if (!viewer?.scene || !sdkRef) return;
const scene = viewer.scene;
resetSceneLighting();
scene.pbrEnabled = false;
scene.colorTextureEnabled = true;
if (scene.sao) {
scene.sao.enabled = false;
}
switch (activeLightingMode) {
case "standard":
createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.7 });
createSceneLight("dir", {
dir: [0.8, -0.5, -0.5],
color: [0.67, 0.67, 1.0],
intensity: 0.7,
space: "world",
});
createSceneLight("dir", {
dir: [-0.8, -1.0, 0.5],
color: [1.0, 1.0, 0.9],
intensity: 0.9,
space: "world",
});
break;
case "soft":
createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.82 });
createSceneLight("dir", {
dir: [0.7, -0.65, -0.55],
color: [1.0, 1.0, 1.0],
intensity: 0.22,
space: "view",
});
createSceneLight("dir", {
dir: [-0.6, -0.35, 0.7],
color: [0.82, 0.88, 1.0],
intensity: 0.16,
space: "view",
});
break;
case "technical":
default:
createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.62 });
createSceneLight("dir", {
dir: [0.9, -0.45, -0.35],
color: [0.95, 0.98, 1.0],
intensity: 0.34,
space: "view",
});
createSceneLight("dir", {
dir: [-0.55, -0.75, 0.45],
color: [1.0, 0.96, 0.88],
intensity: 0.24,
space: "view",
});
if (scene.sao) {
scene.sao.enabled = true;
scene.sao.intensity = 0.08;
scene.sao.bias = 0.7;
scene.sao.kernelRadius = 48;
scene.sao.numSamples = 20;
}
break;
}
scene.glRedraw?.();
if (announce) {
const label = LIGHTING_MODE_OPTIONS.find((option) => option.value === activeLightingMode)?.label || "Свет";
setStatus(`Свет: ${label}`);
}
};
const cloneVec3 = (vec, fallback = [0, 0, 0]) => (
Array.isArray(vec) || ArrayBuffer.isView(vec) ? Array.from(vec).slice(0, 3) : [...fallback]
);
const normalizeVec3 = (vec, fallback = [0, 0, 1]) => {
const [x, y, z] = cloneVec3(vec, fallback);
const length = Math.hypot(x, y, z);
return length > 0.00001 ? [x / length, y / length, z / length] : [...fallback];
};
const dotVec3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const getNavigationAxisPreset = (axis) => {
if (axis === "auto") {
const worldAxis = defaultCameraWorldAxis || NAVIGATION_AXIS_PRESETS["y-up"].worldAxis;
return {
worldAxis,
up: defaultCameraUp || NAVIGATION_AXIS_PRESETS["y-up"].up,
forward: worldAxis.length >= 9 ? worldAxis.slice(6, 9) : NAVIGATION_AXIS_PRESETS["y-up"].forward,
};
}
return NAVIGATION_AXIS_PRESETS[axis] || NAVIGATION_AXIS_PRESETS["y-up"];
};
const applyNavigationAxis = ({ resetAuto = true, announce = false } = {}) => {
activeNavigationAxis = normalizeNavigationAxis(activeNavigationAxis);
setNavigationAxisControlState();
if (!viewer?.camera) return;
if (activeNavigationAxis === "auto" && !resetAuto) return;
const preset = getNavigationAxisPreset(activeNavigationAxis);
const camera = viewer.camera;
const up = normalizeVec3(preset.up, [0, 1, 0]);
const eye = cloneVec3(camera.eye, [0, 0, 10]);
const look = cloneVec3(camera.look, [0, 0, 0]);
const eyeVector = [eye[0] - look[0], eye[1] - look[1], eye[2] - look[2]];
const viewDirection = normalizeVec3(eyeVector, preset.forward);
const distance = Math.hypot(eyeVector[0], eyeVector[1], eyeVector[2]) || 10;
camera.worldAxis = [...preset.worldAxis];
camera.gimbalLock = true;
if (Math.abs(dotVec3(viewDirection, up)) > 0.96) {
const forward = normalizeVec3(preset.forward, [0, 0, 1]);
camera.eye = [
look[0] + forward[0] * distance,
look[1] + forward[1] * distance,
look[2] + forward[2] * distance,
];
}
camera.up = [...up];
viewer.scene?.glRedraw?.();
rearmCameraControl();
if (announce) {
const label = NAVIGATION_AXIS_OPTIONS.find((option) => option.value === activeNavigationAxis)?.label || "Авто";
setStatus(`Ось навигации: ${label}`);
}
};
const toggleProjection = () => {
if (!viewer?.camera) return;
const cam = viewer.camera;
const next = cam.projection === "ortho" ? "perspective" : "ortho";
const eye = cam.eye ? [...cam.eye] : [0, 0, 10];
const look = cam.look ? [...cam.look] : [0, 0, 0];
const dx = eye[0] - look[0];
const dy = eye[1] - look[1];
const dz = eye[2] - look[2];
const dist = Math.hypot(dx, dy, dz) || 1;
const dir = dist > 0 ? [dx / dist, dy / dist, dz / dist] : [0, 0, 1];
const fovDeg = cam.perspective?.fov || 60;
const fovRad = (fovDeg * Math.PI) / 180;
const boundary = viewer.scene?.canvas?.boundary;
const canvas = viewer.scene?.canvas?.canvas;
const width = (boundary && boundary[2]) || canvas?.offsetWidth || canvas?.clientWidth || 1;
const height = (boundary && boundary[3]) || canvas?.offsetHeight || canvas?.clientHeight || 1;
const aspect = width > 0 && height > 0 ? width / height : 1;
if (next === "ortho") {
const perspHeight = 2 * dist * Math.tan(fovRad / 2);
const perspWidth = perspHeight * aspect;
const scale = aspect >= 1 ? perspWidth : perspHeight;
if (cam.ortho) cam.ortho.scale = scale;
cam.projection = "ortho";
} else {
const scale = cam.ortho?.scale || 1;
const orthoHeight = aspect >= 1 ? scale / aspect : scale;
const targetDist = (orthoHeight / 2) / Math.tan(fovRad / 2);
cam.eye = [look[0] + dir[0] * targetDist, look[1] + dir[1] * targetDist, look[2] + dir[2] * targetDist];
cam.projection = "perspective";
}
updateProjectionButton();
setStatus(next === "ortho" ? "Аксонометрия" : "Перспектива");
};
const closeSettingsPanel = () => {
settingsMenu?.hide();
setToolbarButtonActive(settingsButton, false);
};
const closeTemplatesPanel = () => {
templatesMenu?.hide();
setToolbarButtonActive(templatesButton, false);
};
const openSettingsPanel = () => {
closeTemplatesPanel();
settingsMenu?.show();
bringFloatingWindowToFront("settings");
setToolbarButtonActive(settingsButton, true);
};
const openTemplatesPanel = () => {
closeSettingsPanel();
templatesMenu?.show();
bringFloatingWindowToFront("templates");
setToolbarButtonActive(templatesButton, true);
};
const cssValue = (name, fallback = "") => {
const styles = getComputedStyle(document.documentElement);
const v = styles.getPropertyValue(name);
return (v && v.trim()) || fallback;
};
const clampNumber = (value, min, max, fallback) => {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(min, Math.min(max, num));
};
let toolbarDockHideTimer = null;
let toolbarDockAnimationFrame = null;
let toolbarDockLastTs = 0;
const toolbarDockState = new WeakMap();
const TOOLBAR_DOCK_BASE_SCALE = 2.25;
const TOOLBAR_DOCK_BASE_DISTANCE = 110;
const TOOLBAR_DOCK_BASE_NUDGE = 40;
const TOOLBAR_DOCK_NUDGE_TIGHTNESS = 0.82;
const TOOLBAR_DOCK_SPRING = {
mass: 0.1,
stiffness: 170,
damping: 12,
};
const TOOLBAR_DOCK_MAX_STEP = 1 / 240;
const getVisibleToolbarButtons = () => {
if (!bottomToolbar) return [];
return Array.from(bottomToolbar.querySelectorAll(".tb-btn")).filter((btn) => {
if (btn.hidden || btn.classList.contains("hidden")) return false;
return getComputedStyle(btn).display !== "none";
});
};
const readToolbarDockSettings = () => {
const minSize = clampNumber(parseFloat(cssValue("--tb-min-size", "25")), 18, 88, 25);
const maxSize = Math.max(minSize, clampNumber(parseFloat(cssValue("--tb-max-size", "56")), 18, 96, 56));
const rawLens = Math.round(clampNumber(parseFloat(cssValue("--tb-lens-count", "7")), 1, 15, 7));
const lensCount = rawLens % 2 === 0 ? rawLens + 1 : rawLens;
return {
minSize,
maxSize,
lensCount,
autoHide: cssValue("--tb-autohide", "0") === "1",
};
};
const getToolbarDockState = (btn) => {
let state = toolbarDockState.get(btn);
if (!state) {
state = {
scale: 1,
targetScale: 1,
scaleVelocity: 0,
shiftY: 0,
targetShiftY: 0,
shiftVelocity: 0,
};
toolbarDockState.set(btn, state);
}
return state;
};
const stepToolbarSpring = (value, velocity, target, dt) => {
let nextValue = value;
let nextVelocity = velocity;
const steps = Math.max(1, Math.ceil(dt / TOOLBAR_DOCK_MAX_STEP));
const step = dt / steps;
for (let i = 0; i < steps; i += 1) {
const displacement = nextValue - target;
const springForce = -TOOLBAR_DOCK_SPRING.stiffness * displacement;
const dampingForce = -TOOLBAR_DOCK_SPRING.damping * nextVelocity;
const acceleration = (springForce + dampingForce) / TOOLBAR_DOCK_SPRING.mass;
nextVelocity += acceleration * step;
nextValue += nextVelocity * step;
}
return [nextValue, nextVelocity];
};
const renderToolbarDockFrame = (timestamp = performance.now()) => {
const buttons = getVisibleToolbarButtons();
if (!buttons.length) {
toolbarDockAnimationFrame = null;
return;
}
const dt = toolbarDockLastTs ? Math.min(0.032, Math.max(0.001, (timestamp - toolbarDockLastTs) / 1000)) : 0.016;
toolbarDockLastTs = timestamp;
let active = false;
buttons.forEach((btn) => {
const state = getToolbarDockState(btn);
[state.scale, state.scaleVelocity] = stepToolbarSpring(state.scale, state.scaleVelocity, state.targetScale, dt);
[state.shiftY, state.shiftVelocity] = stepToolbarSpring(state.shiftY, state.shiftVelocity, state.targetShiftY, dt);
if (
Math.abs(state.scale - state.targetScale) < 0.001 &&
Math.abs(state.scaleVelocity) < 0.001 &&
Math.abs(state.shiftY - state.targetShiftY) < 0.05 &&
Math.abs(state.shiftVelocity) < 0.05
) {
state.scale = state.targetScale;
state.scaleVelocity = 0;
state.shiftY = state.targetShiftY;
state.shiftVelocity = 0;
} else {
active = true;
}
btn.style.setProperty("--tb-scale", state.scale.toFixed(4));
btn.style.setProperty("--tb-shift-y", `${state.shiftY.toFixed(2)}px`);
});
if (active) {
toolbarDockAnimationFrame = window.requestAnimationFrame(renderToolbarDockFrame);
} else {
toolbarDockAnimationFrame = null;
toolbarDockLastTs = 0;
}
};
const startToolbarDockAnimation = () => {
if (toolbarDockAnimationFrame) return;
toolbarDockLastTs = 0;
toolbarDockAnimationFrame = window.requestAnimationFrame(renderToolbarDockFrame);
};
const setToolbarButtonTarget = (btn, size, shiftY, settings) => {
const state = getToolbarDockState(btn);
state.targetScale = settings.minSize > 0 ? size / settings.minSize : 1;
state.targetShiftY = shiftY;
startToolbarDockAnimation();
};
const resetToolbarDockMagnification = () => {
const settings = readToolbarDockSettings();
bottomToolbar?.classList.remove("toolbar-dock-active");
getVisibleToolbarButtons().forEach((btn) => setToolbarButtonTarget(btn, settings.minSize, 0, settings));
if (sectionModeActive) {
positionSectionToolbar();
}
};
const updateToolbarDockMagnification = (clientY) => {
const buttons = getVisibleToolbarButtons();
if (!buttons.length) return;
const settings = readToolbarDockSettings();
const toolbarRect = bottomToolbar.getBoundingClientRect();
const entries = buttons.map((btn) => {
return {
btn,
centerY: toolbarRect.top + btn.offsetTop + btn.offsetHeight / 2,
};
}).sort((a, b) => a.centerY - b.centerY);
const gap = parseFloat(cssValue("--tb-gap", "10")) || 10;
const step = settings.minSize + gap;
const lensRadius = Math.max(
settings.minSize,
Math.floor((settings.lensCount - 1) / 2) * step + gap / 2
);
const maxScale = settings.minSize > 0 ? settings.maxSize / settings.minSize : 1;
const scaledNudge = TOOLBAR_DOCK_BASE_NUDGE *
(lensRadius / TOOLBAR_DOCK_BASE_DISTANCE) *
Math.max(0, maxScale - 1) / (TOOLBAR_DOCK_BASE_SCALE - 1) *
TOOLBAR_DOCK_NUDGE_TIGHTNESS;
entries.forEach((entry) => {
const distance = clientY - entry.centerY;
const absDistance = Math.abs(distance);
const scale = absDistance >= lensRadius
? 1
: 1 + (maxScale - 1) * (1 - absDistance / lensRadius);
const size = settings.minSize * scale;
const shiftY = absDistance > lensRadius
? Math.sign(distance || 1) * -1 * scaledNudge
: (-distance / lensRadius) * scaledNudge * scale;
setToolbarButtonTarget(entry.btn, size, shiftY, settings);
});
if (sectionModeActive) {
positionSectionToolbar();
}
};
const requestToolbarDockMagnification = (clientY) => {
updateToolbarDockMagnification(clientY);
};
const showToolbarDock = () => {
if (!bottomToolbar) return;
window.clearTimeout(toolbarDockHideTimer);
toolbarDockHideTimer = null;
bottomToolbar.classList.add("is-visible");
};
const scheduleToolbarDockHide = () => {
if (!bottomToolbar) return;
const settings = readToolbarDockSettings();
if (!settings.autoHide) return;
if (toolbarDockHideTimer) return;
toolbarDockHideTimer = window.setTimeout(() => {
toolbarDockHideTimer = null;
bottomToolbar.classList.remove("is-visible");
resetToolbarDockMagnification();
}, 5000);
};
const updateToolbarDockMode = () => {
if (!bottomToolbar) return;
const settings = readToolbarDockSettings();
bottomToolbar.classList.toggle("toolbar-autohide", settings.autoHide);
bottomToolbar.classList.toggle("is-visible", !settings.autoHide);
if (!settings.autoHide) {
window.clearTimeout(toolbarDockHideTimer);
toolbarDockHideTimer = null;
}
resetToolbarDockMagnification();
};
const setupToolbarDock = () => {
if (!bottomToolbar) return;
bottomToolbar.addEventListener("pointermove", (event) => {
showToolbarDock();
bottomToolbar.classList.add("toolbar-dock-active");
requestToolbarDockMagnification(event.clientY);
});
bottomToolbar.addEventListener("pointerenter", () => {
showToolbarDock();
bottomToolbar.classList.add("toolbar-dock-active");
});
bottomToolbar.addEventListener("pointerleave", () => {
resetToolbarDockMagnification();
scheduleToolbarDockHide();
});
window.addEventListener("pointermove", (event) => {
const settings = readToolbarDockSettings();
if (!settings.autoHide || isGuestMode()) return;
if (event.clientX <= Math.max(32, settings.maxSize * 0.75)) {
showToolbarDock();
} else if (!bottomToolbar.matches(":hover")) {
scheduleToolbarDockHide();
}
}, { passive: true });
window.addEventListener("resize", () => {
resetToolbarDockMagnification();
updateToolbarDockMode();
});
updateToolbarDockMode();
};
const hexToRgb01 = (hex) => {
if (!hex) return [1, 1, 1];
const clean = hex.trim();
if (clean.startsWith("rgb")) {
const parts = clean.replace(/rgba?\(/, "").replace(")", "").split(",").map((p) => parseFloat(p.trim()) || 0);
return [(parts[0] || 0) / 255, (parts[1] || 0) / 255, (parts[2] || 0) / 255];
}
const h = clean.replace("#", "");
const norm = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const num = parseInt(norm, 16);
if (Number.isNaN(num)) return [1, 1, 1];
return [(num >> 16 & 255) / 255, (num >> 8 & 255) / 255, (num & 255) / 255];
};
const measureThemeFromCSS = () => {
const pick = (name, fallback) => {
const v = cssValue(name);
return v || fallback;
};
return {
line: pick("--measure-line", "#00bbff"),
label: pick("--measure-label", "#1f2a3c"),
labelText: pick("--measure-label-text", "#ffffff"),
dot: pick("--measure-dot", "#00bbff"),
};
};
const applyMeasureTheme = () => {
if (!measurePlugin) return;
const theme = measureThemeFromCSS();
measurePlugin.defaultColor = theme.line;
const applyToMeasurement = (m) => {
if (!m) return;
m.color = theme.line;
m._lengthLabel?.setFillColor?.(theme.label);
m._originDot?.setFillColor?.(theme.dot);
m._originDot?.setBorderColor?.(theme.line);
m._targetDot?.setFillColor?.(theme.dot);
m._targetDot?.setBorderColor?.(theme.line);
};
Object.values(measurePlugin.measurements || {}).forEach(applyToMeasurement);
document.querySelectorAll(".viewer-ruler-label").forEach((el) => {
el.style.color = theme.labelText;
el.style.background = theme.label;
el.style.borderColor = theme.line;
});
document.querySelectorAll(".viewer-ruler-dot").forEach((el) => {
el.style.background = theme.dot;
el.style.borderColor = theme.line;
});
document.querySelectorAll(".viewer-ruler-dot-clickable").forEach((el) => {
el.style.borderColor = theme.line;
});
};
const sectionPlaneThemeFromCSS = () => ({
axes: {
x: hexToRgb01(cssValue("--section-axis-x", "#ff2b2b")),
y: hexToRgb01(cssValue("--section-axis-y", "#35ff4f")),
z: hexToRgb01(cssValue("--section-axis-z", "#285bff")),
},
gizmoDiameter: clampNumber(parseFloat(cssValue("--section-gizmo-diameter", "1")), 0.5, 1.8, 1),
gizmoThickness: clampNumber(parseFloat(cssValue("--section-gizmo-thickness", "1")), 0.5, 2.5, 1),
planeFillColor: hexToRgb01(cssValue("--section-plane-fill", "#606060")),
planeFillAlpha: clampNumber(parseFloat(cssValue("--section-plane-alpha", "0.32")), 0, 1, 0.32),
planeEdgeColor: hexToRgb01(cssValue("--section-plane-edge", "#0d0d0d")),
planeEdgeAlpha: clampNumber(parseFloat(cssValue("--section-plane-edge-alpha", "0.8")), 0, 1, 0.8),
});
const applySectionPlaneTheme = () => {
if (!sectionPlanesPlugin) return;
const theme = sectionPlaneThemeFromCSS();
Object.values(sectionPlanesPlugin._controls || {}).forEach((control) => {
control?.setStyle?.(theme);
});
Object.values(sectionPlanesPlugin._overview?._planes || {}).forEach((plane) => {
const mesh = plane?._mesh;
if (!mesh) return;
if (mesh.material) {
mesh.material.diffuse = theme.planeFillColor;
mesh.material.emissive = theme.planeFillColor;
mesh.material.alpha = theme.planeFillAlpha;
mesh.material.alphaMode = theme.planeFillAlpha < 1 ? "blend" : "opaque";
}
if (mesh.edgeMaterial) {
mesh.edgeMaterial.edgeColor = theme.planeEdgeColor;
mesh.edgeMaterial.edgeAlpha = theme.planeEdgeAlpha;
}
if (mesh.highlightMaterial) {
mesh.highlightMaterial.fillColor = theme.planeFillColor;
mesh.highlightMaterial.fillAlpha = theme.planeFillAlpha;
mesh.highlightMaterial.edgeColor = theme.planeEdgeColor;
mesh.highlightMaterial.edgeAlpha = theme.planeEdgeAlpha;
}
if (mesh.selectedMaterial) {
mesh.selectedMaterial.fillColor = theme.planeFillColor;
mesh.selectedMaterial.fillAlpha = theme.planeFillAlpha;
mesh.selectedMaterial.edgeColor = theme.planeEdgeColor;
mesh.selectedMaterial.edgeAlpha = theme.planeEdgeAlpha;
}
mesh.opacity = theme.planeFillAlpha;
});
viewer?.scene?.glRedraw?.();
sectionPlanesPlugin._overview?._scene?.glRedraw?.();
};
const applyHighlightTheme = () => {
if (!viewer?.scene?.highlightMaterial) return;
const hexToRGB = (hex) => {
const h = hex.replace("#", "");
const num = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);
if (Number.isNaN(num)) return [0.72, 0.2, 1];
return [(num >> 16 & 255) / 255, (num >> 8 & 255) / 255, (num & 255) / 255];
};
const hl = cssValue("--highlight-color", "#b833ff");
const color = hexToRGB(hl);
viewer.scene.highlightMaterial.color = color;
if (viewer.scene.highlightMaterial.edgeColor) {
viewer.scene.highlightMaterial.edgeColor = color;
}
if (viewer.scene.highlightMaterial.fillColor) {
viewer.scene.highlightMaterial.fillColor = [...color, 0.5];
}
viewer.scene.glRedraw();
};
const normalizeType = (type, src = "") => {
const lower = (type || "").toLowerCase();
if (lower === "glb" || lower === "gltf") return "gltf";
if (lower === "laz" || lower === "las" || lower === "pointcloud" || lower === "point_cloud") return "las";
if (lower === "xkt") return "xkt";
if (lower === "bim") return "bim";
if (lower === "obj") return "obj";
if (lower === "stl") return "stl";
if (src) {
return normalizeType(guessTypeFromName(src));
}
return lower || null;
};
const log = (text, isError = false) => {
if (!logList) return;
const entry = document.createElement("div");
entry.textContent = text;
entry.style.color = isError ? "var(--error)" : "var(--muted)";
logList.appendChild(entry);
while (logList.children.length > 20) {
logList.removeChild(logList.firstChild);
}
logList.scrollTop = logList.scrollHeight;
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 = "";
log(text, false);
};
const showError = (text) => {
statusText.textContent = text;
statusText.style.color = "var(--error)";
setTimeout(() => {
statusText.style.color = "";
}, 2000);
log(text, true);
};
const setLoading = (flag) => {
if (!loaderEl) return;
if (flag) {
loaderEl.classList.remove("hidden");
} else {
loaderEl.classList.add("hidden");
}
};
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";
};
const setMeasureActive = (active) => {
const isActive = !!active && !!measurePlugin && !!measureControl;
measureActive = isActive;
if (measureControl) {
if (isActive) {
measureControl.activate();
} else {
measureControl.deactivate();
}
}
if (measureButton) {
measureButton.classList.toggle("active", isActive);
}
if (!isActive) {
closeMeasurementModal();
}
};
const closeMeasurementModal = () => {
measurementModal?.hide?.();
activeMeasurementId = null;
};
const clearMeasurements = () => {
measurePlugin?.clear?.();
closeMeasurementModal();
};
const getSectionPlaneIds = () => Object.keys(sectionPlanesPlugin?.sectionPlanes || {});
const getActiveSectionPlane = () => {
if (!sectionPlanesPlugin) return null;
if (activeSectionPlaneId && sectionPlanesPlugin.sectionPlanes?.[activeSectionPlaneId]) {
return sectionPlanesPlugin.sectionPlanes[activeSectionPlaneId];
}
const firstId = getSectionPlaneIds()[0] || null;
activeSectionPlaneId = firstId;
return firstId ? sectionPlanesPlugin.sectionPlanes[firstId] : null;
};
const setAllSectionPlanesActive = (active) => {
getSectionPlaneIds().forEach((id) => {
const sectionPlane = sectionPlanesPlugin?.sectionPlanes?.[id];
if (sectionPlane) {
sectionPlane.active = !!active;
}
});
};
const getSectionCenter = () => {
const sceneAABB = viewer?.scene?.aabb;
const aabb = Array.isArray(sceneAABB) || ArrayBuffer.isView(sceneAABB)
? Array.from(sceneAABB)
: null;
const center = centerFromAABB(aabb);
return center.every((value) => Number.isFinite(value)) ? center : [0, 0, 0];
};
const getCameraSectionDir = () => {
const eye = snapshotVec(viewer?.camera?.eye) || [0, 0, 1];
const look = snapshotVec(viewer?.camera?.look) || [0, 0, 0];
return normalizeVec3([
look[0] - eye[0],
look[1] - eye[1],
look[2] - eye[2],
], [0, 0, -1]);
};
const setSectionAxisActive = (axis) => {
activeSectionAxis = axis || null;
sectionToolbar?.querySelectorAll("[data-section-action]").forEach((btn) => {
btn.classList.toggle("active", !!axis && btn.dataset.sectionAction === axis);
});
};
const positionSectionToolbar = () => {
if (!sectionButton || !sectionToolbar) return;
const rect = sectionButton.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const gap = parseFloat(cssValue("--tb-gap", "10")) || 10;
const root = document.documentElement;
root.style.setProperty("--section-toolbar-left", `${Math.round(rect.right + gap)}px`);
root.style.setProperty("--section-toolbar-bottom", `${Math.round(Math.max(8, window.innerHeight - rect.bottom))}px`);
};
const setSectionModeActive = (active) => {
sectionModeActive = !!active && !!sectionPlanesPlugin && getSectionPlaneIds().length > 0;
if (sectionModeActive) {
positionSectionToolbar();
}
sectionButton?.classList.toggle("active", sectionModeActive);
sectionToolbar?.classList.toggle("hidden", !sectionModeActive);
document.body.dataset.sectionMode = sectionModeActive ? "active" : "";
sectionPlanesPlugin?.setOverviewVisible?.(sectionModeActive);
if (!sectionModeActive) {
setSectionAxisActive(null);
sectionPlanesPlugin?.hideControl?.();
}
};
const updateSectionControls = () => {
const available = !!sectionPlanesPlugin && activeModels.length > 0;
if (sectionButton) {
sectionButton.disabled = !available;
sectionButton.classList.toggle("disabled", !available);
sectionButton.classList.toggle("active", available && sectionModeActive);
}
if (!available) {
sectionToolbar?.classList.add("hidden");
document.body.dataset.sectionMode = "";
sectionPlanesPlugin?.setOverviewVisible?.(false);
}
};
const createOrUpdateSectionPlane = (axis = "camera") => {
if (!sectionPlanesPlugin) {
showError("Разрез недоступен в текущей сборке SDK");
return null;
}
if (!activeModels.length) {
showError("Сначала загрузите модель");
return null;
}
setMeasureActive(false);
const dirs = {
camera: getCameraSectionDir(),
x: [1, 0, 0],
y: [0, 1, 0],
z: [0, 0, 1],
};
const dir = dirs[axis] || dirs.camera;
const pos = getSectionCenter();
let sectionPlane = getActiveSectionPlane();
if (!sectionPlane) {
const id = `sectionPlane-${++sectionPlaneCounter}`;
sectionPlane = sectionPlanesPlugin.createSectionPlane({ id, pos, dir });
activeSectionPlaneId = sectionPlane.id || id;
} else {
sectionPlane.pos = pos;
sectionPlane.dir = dir;
sectionPlane.active = true;
activeSectionPlaneId = sectionPlane.id || activeSectionPlaneId;
}
applySectionPlaneTheme();
sectionPlanesPlugin.showControl(activeSectionPlaneId);
setSectionModeActive(true);
setSectionAxisActive(axis);
setStatus("Разрез включён");
rearmCameraControl();
return sectionPlane;
};
const clearSectionPlanes = () => {
sectionPlanesPlugin?.clear?.();
activeSectionPlaneId = null;
setSectionModeActive(false);
};
const flipSectionPlanes = () => {
if (!getActiveSectionPlane()) {
createOrUpdateSectionPlane("camera");
return;
}
setAllSectionPlanesActive(true);
sectionPlanesPlugin?.flipSectionPlanes?.();
sectionPlanesPlugin?.showControl?.(activeSectionPlaneId);
setSectionModeActive(true);
setStatus("Разрез инвертирован");
};
const toggleSectionPlanes = () => {
if (sectionModeActive) {
setAllSectionPlanesActive(false);
setSectionModeActive(false);
setStatus("Разрез выключен");
return;
}
if (getSectionPlaneIds().length > 0) {
setAllSectionPlanesActive(true);
activeSectionPlaneId = activeSectionPlaneId || getSectionPlaneIds()[0];
sectionPlanesPlugin?.showControl?.(activeSectionPlaneId);
setSectionModeActive(true);
setStatus("Разрез включён");
return;
}
createOrUpdateSectionPlane("camera");
};
const rebuildNavCube = (sdk) => {
if (!navCubeCanvas || !viewer) return;
const cubeColor = cssValue("--cube-base", "#7b3fff");
const cubeHover = cssValue("--cube-hover", "rgba(255,255,255,0.4)");
const cubeText = cssValue("--cube-text", "#ffffff");
const edgeColorHex = cssValue("--cube-edge-color", "#e0e7ff");
const edgeAlpha = parseFloat(cssValue("--cube-edge-alpha", "0.6")) || 0.6;
if (!navCubePlugin) {
navCubePlugin = new sdk.NavCubePlugin(viewer, {
canvasId: "navCube",
size: 100,
visible: true,
color: cubeColor,
textColor: cubeText,
hoverColor: cubeHover,
});
}
// update textures/colors on existing plugin without recreating WebGL context
const navScene = navCubePlugin?._navCubeScene;
const cubeTextureCtor = navCubePlugin?._cubeTextureCanvas?.constructor;
if (cubeTextureCtor && navScene) {
navCubePlugin._cubeTextureCanvas?.destroy?.();
const ctc = new cubeTextureCtor(viewer, navScene, {
color: cubeColor,
hoverColor: cubeHover,
textColor: cubeText,
});
ctc.clear?.();
navCubePlugin._cubeTextureCanvas = ctc;
const image = ctc.getImage();
if (navCubePlugin._cubeSampler) {
navCubePlugin._cubeSampler.image = image;
navCubePlugin._cubeSampler._imageDirty = true;
navCubePlugin._cubeSampler.glTexture = null;
}
if (navCubePlugin._cubeMesh?.material) {
navCubePlugin._cubeMesh.material.diffuseMap.image = image;
navCubePlugin._cubeMesh.material.diffuseMap._imageDirty = true;
navCubePlugin._cubeMesh.material.diffuseMap.glTexture = null;
navCubePlugin._cubeMesh.material.emissiveMap.image = image;
navCubePlugin._cubeMesh.material.emissiveMap._imageDirty = true;
navCubePlugin._cubeMesh.material.emissiveMap.glTexture = null;
}
navCubePlugin._repaint?.();
}
if (navScene?.edgeMaterial) {
navScene.edgeMaterial.edgeColor = hexToRgb01(edgeColorHex);
navScene.edgeMaterial.edgeAlpha = edgeAlpha;
navScene.glRedraw();
}
updateNavCubeVisibility();
};
const updatePanelsVisibility = () => {
const hasModels = activeModels.length > 0;
if (sidePanelEl) {
sidePanelEl.classList.toggle("is-collapsed", hasModels && inspectorCollapsed);
sidePanelEl.style.display = hasModels && !inspectorCollapsed ? "flex" : "none";
}
if (inspectorRestoreTray) {
inspectorRestoreTray.hidden = !hasModels || !inspectorCollapsed;
}
if (treeContainer) treeContainer.classList.toggle("hidden", !hasModels);
const showActions = hasModels || !!currentProjectMeta || !!lastAttemptedProjectId;
if (saveProjectSection) {
saveProjectSection.classList.toggle("hidden", isGuestMode() || !showActions);
}
if (projectNameSection) {
projectNameSection.classList.toggle("hidden", isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId));
}
if (saveProjectButton) {
saveProjectButton.disabled = isGuestMode() || !hasModels;
}
if (saveModelSettingsButton) {
saveModelSettingsButton.disabled = isGuestMode() || !hasModels || !hasModelSettingsTarget();
}
updateModelLodControl();
if (deleteProjectButton) {
deleteProjectButton.disabled = isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId);
}
if (projectNameInput) {
const hasProject = !isGuestMode() && !!(currentProjectMeta || lastAttemptedProjectId);
projectNameInput.disabled = !hasProject;
if (!hasProject) {
projectNameInput.value = "";
}
}
if (!hasModels) {
commentsModeActive = false;
commentsCollapsed = false;
commentContextTarget = null;
activeCommentId = null;
hoverCommentId = null;
commentRecords = [];
commentSourcesLoaded.clear();
closeCommentModal();
}
updateSectionControls();
updateCommentsVisibility();
updateModelVersionControl();
applyRuntimeMode();
};
const setInspectorCollapsed = (collapsed) => {
inspectorCollapsed = !!collapsed;
if (inspectorCollapseButton) {
inspectorCollapseButton.setAttribute("aria-expanded", String(!inspectorCollapsed));
}
updatePanelsVisibility();
focusCanvasAndControls();
};
const centerFromAABB = (aabb) => {
if (!Array.isArray(aabb) || aabb.length !== 6) return [0, 0, 0];
return [
(aabb[0] + aabb[3]) / 2,
(aabb[1] + aabb[4]) / 2,
(aabb[2] + aabb[5]) / 2,
];
};
const updateGlobalInputs = (pos) => {
if (!globalInputs.x || !globalInputs.y || !globalInputs.z) return;
const safe = pos && pos.length === 3 ? pos : ["", "", ""];
globalInputs.x.value = safe[0];
globalInputs.y.value = safe[1];
globalInputs.z.value = safe[2];
};
const getAABBForSelection = (ids, modelId = null) => {
if (!viewer || !viewer.scene) return null;
const scene = viewer.scene;
if (ids && ids.length) {
return Array.from(scene.getAABB(ids) || []);
}
if (modelId && scene.models?.[modelId]) {
const model = scene.models[modelId];
const idsFromModel = Object.keys(model.objects || {});
return Array.from(scene.getAABB(idsFromModel) || model.aabb || []);
}
return null;
};
const applyGlobalTarget = () => {
if (!viewer || !viewer.scene) return;
const scene = viewer.scene;
const target = [
parseFloat(globalInputs.x?.value),
parseFloat(globalInputs.y?.value),
parseFloat(globalInputs.z?.value),
];
try {
if (!target.every((v) => Number.isFinite(v))) {
console.warn("[GlobalCoords] invalid target", target);
return;
}
const ids = lastSelection.ids || [];
const modelId = lastSelection.modelId;
const currentAABB = getAABBForSelection(ids, modelId);
if (!currentAABB || currentAABB.length !== 6) {
console.warn("[GlobalCoords] no AABB for selection");
return;
}
const currentCenter = centerFromAABB(currentAABB);
const delta = [
target[0] - currentCenter[0],
target[1] - currentCenter[1],
target[2] - currentCenter[2],
];
// если есть модель — сдвигаем модель
if (modelId && scene.models?.[modelId]) {
const model = scene.models[modelId];
const basePos = model.position || [0, 0, 0];
model.position = [
(basePos[0] || 0) + delta[0],
(basePos[1] || 0) + delta[1],
(basePos[2] || 0) + delta[2],
];
// persist transform
persistTransform(modelId, "__model__", {
position: [...model.position],
rotation: model.rotation ? [...model.rotation] : [0, 0, 0],
scale: model.scale ? [...model.scale] : [1, 1, 1],
});
} else if (ids.length) {
// иначе сдвигаем объекты по отдельности
ids.forEach((id) => {
const ent = scene.objects?.[id];
if (!ent) return;
const basePos = ent.position || [0, 0, 0];
ent.position = [
(basePos[0] || 0) + delta[0],
(basePos[1] || 0) + delta[1],
(basePos[2] || 0) + delta[2],
];
if (modelId) {
persistTransform(modelId, id, {
position: [...ent.position],
rotation: ent.rotation ? [...ent.rotation] : [0, 0, 0],
scale: ent.scale ? [...ent.scale] : [1, 1, 1],
});
}
});
} else {
console.warn("[GlobalCoords] nothing selected");
return;
}
// обновляем отображение и состояние
const newAABB = getAABBForSelection(ids, modelId);
updateGlobalInputs(centerFromAABB(newAABB || []));
scene.setAABBDirty?.();
scene.glRedraw?.();
console.log("[GlobalCoords] applied delta", { delta, target, modelId, count: ids.length });
} finally {
ensureCameraControlActive();
resetCameraControl();
if (viewer?.cameraControl) {
viewer.cameraControl.enabled = true;
viewer.cameraControl.active = true;
}
focusCanvasAndControls();
}
};
["x", "y", "z"].forEach((key) => {
const el = globalInputs[key];
if (!el) return;
el.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
applyGlobalTarget();
}
});
el.addEventListener("blur", () => {
applyGlobalTarget();
focusCanvasAndControls();
});
});
const scheduleCameraRearm = () => {
rearmCameraControl();
window.setTimeout(rearmCameraControl, 60);
window.setTimeout(rearmCameraControl, 220);
};
window.addEventListener("blur", scheduleCameraRearm);
window.addEventListener("focus", scheduleCameraRearm);
window.addEventListener("pageshow", scheduleCameraRearm);
window.addEventListener("pointerup", scheduleCameraRearm, true);
window.addEventListener("mouseup", scheduleCameraRearm, true);
window.addEventListener("touchend", scheduleCameraRearm, true);
window.addEventListener("touchcancel", scheduleCameraRearm, true);
window.addEventListener("resize", () => {
if (sectionModeActive) {
positionSectionToolbar();
}
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
scheduleCameraRearm();
}
});
const makeTranslationMat = (x, y, z) => {
const m = mathUtil?.identityMat4 ? mathUtil.identityMat4() : [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
if (m.length === 16) {
m[12] = x;
m[13] = y;
m[14] = z;
}
return m;
};
const degToRad = (deg) => (deg * Math.PI) / 180;
const rotateVec3 = (v, rotDeg) => {
const [x, y, z] = v;
const rx = degToRad(rotDeg[0] || 0);
const ry = degToRad(rotDeg[1] || 0);
const rz = degToRad(rotDeg[2] || 0);
// X
let cy = Math.cos(rx), sy = Math.sin(rx);
let v1y = y * cy - z * sy;
let v1z = y * sy + z * cy;
let v1x = x;
// Y
let cz = Math.cos(ry), sz = Math.sin(ry);
let v2x = v1x * cz + v1z * sz;
let v2z = -v1x * sz + v1z * cz;
let v2y = v1y;
// Z
let cx = Math.cos(rz), sx = Math.sin(rz);
let v3x = v2x * cx - v2y * sx;
let v3y = v2x * sx + v2y * cx;
let v3z = v2z;
return [v3x, v3y, v3z];
};
const applyGroupTransform = (transform) => {
if (!selectionState || !viewer) return;
const scene = viewer.scene;
const current = selectionState.transform || {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
};
const pos = transform?.position || current.position || [0, 0, 0];
const rot = transform?.rotation || current.rotation || [0, 0, 0];
const scl = transform?.scale || current.scale || [1, 1, 1];
const deltaPos = [
pos[0] - (current.position?.[0] || 0),
pos[1] - (current.position?.[1] || 0),
pos[2] - (current.position?.[2] || 0),
];
const deltaRot = [
rot[0] - (current.rotation?.[0] || 0),
rot[1] - (current.rotation?.[1] || 0),
rot[2] - (current.rotation?.[2] || 0),
];
const safeScale = [
current.scale?.[0] || 1,
current.scale?.[1] || 1,
current.scale?.[2] || 1,
];
const deltaScale = [
safeScale[0] ? (scl[0] || 1) / safeScale[0] : scl[0] || 1,
safeScale[1] ? (scl[1] || 1) / safeScale[1] : scl[1] || 1,
safeScale[2] ? (scl[2] || 1) / safeScale[2] : scl[2] || 1,
];
selectionState.transform = {
position: [...pos],
rotation: [...rot],
scale: [...scl],
};
let idsToApply = selectionState.ids && selectionState.ids.length ? [...selectionState.ids] : [];
if (!idsToApply.length && selectionState.modelId) {
idsToApply = ["__model__"];
}
const isGroup = idsToApply.length > 1;
const pivot = selectionState.pivotCenter || [0, 0, 0];
let applied = false;
idsToApply.forEach((objectId) => {
const entity = objectId === "__model__"
? viewer.scene?.models?.[selectionState.modelId]
: resolveEntity(objectId, selectionState.modelId);
if (!entity) return;
const basePos = entity.position || [0, 0, 0];
const baseRot = entity.rotation || [0, 0, 0];
const baseScale = entity.scale || [1, 1, 1];
let newPos = [
basePos[0] + deltaPos[0],
basePos[1] + deltaPos[1],
basePos[2] + deltaPos[2],
];
if (isGroup) {
const local = [
basePos[0] - pivot[0],
basePos[1] - pivot[1],
basePos[2] - pivot[2],
];
// масштаб вокруг пивота
const scaled = [
local[0] * deltaScale[0],
local[1] * deltaScale[1],
local[2] * deltaScale[2],
];
const rotated = rotateVec3(scaled, deltaRot);
newPos = [
pivot[0] + rotated[0] + deltaPos[0],
pivot[1] + rotated[1] + deltaPos[1],
pivot[2] + rotated[2] + deltaPos[2],
];
}
entity.position = newPos;
entity.rotation = [
baseRot[0] + deltaRot[0],
baseRot[1] + deltaRot[1],
baseRot[2] + deltaRot[2],
];
entity.scale = [
baseScale[0] * deltaScale[0],
baseScale[1] * deltaScale[1],
baseScale[2] * deltaScale[2],
];
if (selectionState.modelId) {
const key = objectId === "__model__" ? "__model__" : (objectId || "__model__");
persistTransform(selectionState.modelId, key, {
position: [...entity.position],
rotation: [...entity.rotation],
scale: [...entity.scale],
});
}
applied = true;
});
if (!applied && selectionState.modelId && viewer.scene?.models?.[selectionState.modelId]) {
const model = viewer.scene.models[selectionState.modelId];
const basePos = model.position || [0, 0, 0];
const baseRot = model.rotation || [0, 0, 0];
const baseScale = model.scale || [1, 1, 1];
model.position = [
basePos[0] + deltaPos[0],
basePos[1] + deltaPos[1],
basePos[2] + deltaPos[2],
];
model.rotation = [
baseRot[0] + deltaRot[0],
baseRot[1] + deltaRot[1],
baseRot[2] + deltaRot[2],
];
model.scale = [
baseScale[0] * deltaScale[0],
baseScale[1] * deltaScale[1],
baseScale[2] * deltaScale[2],
];
persistTransform(selectionState.modelId, "__model__", {
position: [...model.position],
rotation: [...model.rotation],
scale: [...model.scale],
});
}
// при общем сдвиге переносим пивот
selectionState.pivotCenter = [
(pivot[0] || 0) + deltaPos[0],
(pivot[1] || 0) + deltaPos[1],
(pivot[2] || 0) + deltaPos[2],
];
updateTransformInputs(selectionState.transform);
scene.setAABBDirty?.();
scene.glRedraw?.();
};
const setSelection = (ids, modelId) => {
if (!viewer) return;
const scene = viewer.scene;
let uniqueIds = Array.from(new Set(ids || [])).filter(Boolean);
if ((!uniqueIds.length) && modelId && scene.models?.[modelId]) {
uniqueIds = Object.keys(scene.models[modelId].objects || {});
}
if (!uniqueIds.length && !modelId && activeModels.length) {
modelId = activeModels[0]?.model?.id || activeModels[0]?.id || null;
if (modelId && scene.models?.[modelId]) {
uniqueIds = Object.keys(scene.models[modelId].objects || {});
}
}
if (!uniqueIds.length) {
selectionState = null;
updateTransformInputs(null);
updateGlobalInputs(null);
gizmo?.clearTarget?.();
activeTransformTarget = null;
return;
}
lastSelection.ids = uniqueIds.slice(0);
lastSelection.modelId = modelId || null;
// если выбрали ровно один объект — работаем через гизму как раньше
if (uniqueIds.length === 1) {
const aabbSingle = getAABBForSelection(uniqueIds, modelId);
updateGlobalInputs(centerFromAABB(aabbSingle || []));
selectionState = null;
updateTransformInputs(null);
gizmo?.setTarget?.({ modelId, objectId: uniqueIds[0] });
activeTransformTarget = { modelId, objectId: uniqueIds[0] || "__model__" };
return;
}
const aabb = Array.from(scene.getAABB(uniqueIds) || []);
const pivotCenter = centerFromAABB(aabb);
const baseTransforms = new Map();
uniqueIds.forEach((id) => {
const ent = resolveEntity(id, modelId);
if (ent) {
baseTransforms.set(id, {
position: ent.position ? [...ent.position] : [0, 0, 0],
rotation: ent.rotation ? [...ent.rotation] : [0, 0, 0],
scale: ent.scale ? [...ent.scale] : [1, 1, 1],
});
}
});
if (baseTransforms.size === 0 && modelId && scene.models?.[modelId]) {
const model = scene.models[modelId];
baseTransforms.set("__model__", {
position: model.position ? [...model.position] : [0, 0, 0],
rotation: model.rotation ? [...model.rotation] : [0, 0, 0],
scale: model.scale ? [...model.scale] : [1, 1, 1],
});
}
const firstBase = baseTransforms.values().next().value || {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
};
const transform = {
position: [...(firstBase.position || [0, 0, 0])],
rotation: [...(firstBase.rotation || [0, 0, 0])],
scale: [...(firstBase.scale || [1, 1, 1])],
};
selectionState = {
ids: uniqueIds,
modelId,
pivotCenter,
baseTransforms,
transform,
};
activeTransformTarget = { modelId, objectId: uniqueIds[0] || "__model__" };
scene.setObjectsHighlighted(scene.highlightedObjectIds || [], false);
if (!isPointCloudModel(modelId)) {
scene.setObjectsHighlighted(uniqueIds, true);
}
updateTransformInputs(transform);
// Global center for group
updateGlobalInputs(centerFromAABB(aabb));
};
const collectObjectIds = (treeViewNode) => {
const ids = [];
if (!treeViewNode) return ids;
console.info("[DC Tree] collectObjectIds start", {
nodeId: treeViewNode.nodeId || treeViewNode.id,
title: treeViewNode.title || treeViewNode.text || treeViewNode.name
});
const plugin = treeViewNode.treeViewPlugin || treeView;
if (plugin?.withNodeTree) {
plugin.withNodeTree(treeViewNode, (node) => {
if (node.objectId) {
ids.push(node.objectId);
}
if (node.metaObjectId && viewer?.metaScene?.metaObjects?.[node.metaObjectId]) {
ids.push(...viewer.metaScene.metaObjects[node.metaObjectId].getObjectIDsInSubtree());
}
});
}
if (!ids.length && treeViewNode.metaObjectId && viewer?.metaScene?.metaObjects?.[treeViewNode.metaObjectId]) {
ids.push(...viewer.metaScene.metaObjects[treeViewNode.metaObjectId].getObjectIDsInSubtree());
}
if (!ids.length && treeViewNode.objectId) {
ids.push(treeViewNode.objectId);
}
// fallback: try meta by id
if (!ids.length && treeViewNode.id && viewer?.metaScene?.metaObjects?.[treeViewNode.id]) {
ids.push(...viewer.metaScene.metaObjects[treeViewNode.id].getObjectIDsInSubtree());
}
// last resort: all objects of model
if (!ids.length) {
const modelId = getModelIdFromNode(treeViewNode);
const model = modelId ? viewer?.scene?.models?.[modelId] : null;
if (model) {
ids.push(...Object.keys(model.objects || {}));
}
}
console.info("[DC Tree] collectObjectIds result", { count: ids.length, sample: ids.slice(0, 10) });
return Array.from(new Set(ids));
};
const resolveEntity = (objectId, modelId = null) => {
if (!viewer?.scene?.objects) return null;
const objects = viewer.scene.objects;
if (objects[objectId]) return objects[objectId];
const local = objectId.includes("#") ? objectId.split("#")[1] : objectId;
if (modelId) {
const globalId = `${modelId}#${local}`;
if (objects[globalId]) return objects[globalId];
}
if (objects[local]) return objects[local];
return null;
};
const parseInputNumber = (val) => {
if (val === undefined || val === null) return NaN;
const str = String(val).trim().replace(/,/g, ".");
const num = Number(str);
return Number.isFinite(num) ? num : NaN;
};
const persistTransform = (modelId, objectId, transform) => {
if (isGuestMode()) return;
if (!modelId || !transform) return;
if (!transformStore[modelId]) transformStore[modelId] = {};
const key = objectId || "__model__";
transformStore[modelId][key] = {
position: transform.position ? [...transform.position] : [0, 0, 0],
rotation: transform.rotation ? [...transform.rotation] : [0, 0, 0],
scale: transform.scale ? [...transform.scale] : [1, 1, 1],
};
// Для LAS фиксируем трансформ на уровне модели, т.к. objectId может меняться между загрузками
if (modelTypes[modelId] === "las" && key !== "__model__") {
transformStore[modelId]["__model__"] = {
position: transform.position ? [...transform.position] : [0, 0, 0],
rotation: transform.rotation ? [...transform.rotation] : [0, 0, 0],
scale: transform.scale ? [...transform.scale] : [1, 1, 1],
};
}
};
const applyStoredTransformsToModel = (modelId) => {
if (!viewer || !modelId || !sceneModels[modelId]) return false;
const modelTransforms = transformStore[modelId];
if (!modelTransforms) return false;
let applied = false;
const entries = Object.entries(modelTransforms).filter(([, tr]) => !!tr);
const hasModel = !!modelTransforms.__model__;
const nonModel = entries.filter(([key]) => key !== "__model__");
const isNeutral = (tr = {}) => {
const pos = tr.position || [0, 0, 0];
const rot = tr.rotation || [0, 0, 0];
const scale = tr.scale || [1, 1, 1];
const nearZero = (v) => Math.abs(v) < 1e-6;
return (
pos.every(nearZero) &&
rot.every(nearZero) &&
scale.every((v) => Math.abs(v - 1) < 1e-6)
);
};
// Heuristic: если у GLB/GLTF сохранён единственный трансформ (root-объект) и __model__ пустой,
// считаем его трансформом всей модели. Это устраняет кейс, когда GLB при загрузке остаётся в нулях,
// пока не кликнешь по нему.
const shouldPromote =
(modelTypes[modelId] === "gltf") &&
nonModel.length === 1 &&
(!hasModel || isNeutral(modelTransforms.__model__));
const promoteKey = shouldPromote ? nonModel[0][0] : null;
if (shouldPromote) {
const tr = nonModel[0][1];
const model = sceneModels[modelId];
if (tr && model) {
if (tr.position) model.position = [...tr.position];
if (tr.rotation) model.rotation = [...tr.rotation];
if (tr.scale) model.scale = [...tr.scale];
applied = true;
}
}
// обычное применение: __model__ + остальные объекты (кроме промотнутого root, чтобы не удвоить эффект)
entries.forEach(([objectId, tr]) => {
if (!tr) return;
if (promoteKey && objectId === promoteKey) return;
if (shouldPromote && objectId === "__model__" && isNeutral(tr)) return;
const target =
objectId === "__model__"
? sceneModels[modelId]
: resolveEntity(objectId, modelId);
const applyTarget = target || sceneModels[modelId];
if (!applyTarget) return;
if (tr.position) applyTarget.position = [...tr.position];
if (tr.rotation) applyTarget.rotation = [...tr.rotation];
if (tr.scale) applyTarget.scale = [...tr.scale];
applied = true;
});
if (applied) {
viewer.scene?.setAABBDirty?.();
viewer.scene?.glRedraw?.();
}
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;
return node?.modelId || node?.model?.id || node?.modelIdGiven || findModelIdForObjectId(firstObject) || (title && sceneModels[title] ? title : null);
};
const findModelIdForObjectId = (objectId) => {
if (!objectId || !viewer?.scene?.models) return null;
const models = viewer.scene.models;
for (const mid of Object.keys(models)) {
if (models[mid]?.objects && Object.prototype.hasOwnProperty.call(models[mid].objects, objectId)) {
return mid;
}
}
return null;
};
const highlightTreeNode = (treeViewNode, { fromTree = false } = {}) => {
if (!treeViewNode || !viewer) return;
const objectIds = collectObjectIds(treeViewNode);
const modelId = getModelIdFromNode(treeViewNode) || findModelIdForObjectId(objectIds[0]);
if (treeContainer) {
const prev = treeContainer.querySelector(".highlighted-node");
if (prev) prev.classList.remove("highlighted-node");
const nodeId = treeViewNode.nodeId || treeViewNode.id;
const liEl = nodeId ? treeContainer.querySelector(`li#${CSS.escape(nodeId)}`) : null;
if (liEl) {
liEl.classList.add("highlighted-node");
}
}
if (!fromTree) {
setSelection(objectIds, modelId);
}
};
const flyToObjectIds = (ids, modelId = null) => {
if (!viewer) return;
const scene = viewer.scene;
const idsToUse = (ids && ids.length) ? ids : (selectionState?.ids || []);
console.log("[DC Tree] flyToObjectIds start", {
count: idsToUse.length,
sample: idsToUse.slice(0, 10),
modelId
});
// фильтруем только существующие в сцене
const existing = idsToUse.filter((id) => scene?.objects?.[id]);
if (existing.length !== idsToUse.length) {
console.warn("[DC Tree] filtered missing ids", {
requested: idsToUse.length,
existing: existing.length
});
}
const idsForAABB = existing.length ? existing : idsToUse;
let aabb = null;
if (idsForAABB.length && scene?.getAABB) {
aabb = scene.getAABB(idsForAABB);
} else if (modelId && scene?.models?.[modelId]) {
aabb = scene.models[modelId].aabb || scene.getAABB({ ids: Object.keys(scene.models[modelId].objects || {}) });
}
const aabbArray = Array.from(aabb || []);
console.log("[DC Tree] AABB", aabbArray);
if (!aabbArray.length || aabbArray.length !== 6 || !aabbArray.every((n) => Number.isFinite(n))) {
console.warn("[DC Tree] no valid AABB for ids", { count: idsForAABB.length });
return;
}
const center = centerFromAABB(aabbArray);
if (viewer.cameraControl) {
viewer.cameraControl.pivotPos = center;
}
viewer.cameraFlight.flyTo({ aabb: aabbArray, duration: 0.6 });
rearmCameraControl();
};
const clearCustomTrees = () => {
customTrees.forEach((el) => el.remove());
customTrees.clear();
};
const renderFallbackTree = (modelId) => {
if (!treeContainer) return;
if (customTrees.has(modelId)) return;
const model = sceneModels[modelId];
if (!model) return;
const objectIds = Object.keys(model.objects || {});
const root = document.createElement("ul");
root.className = "custom-tree";
const liRoot = document.createElement("li");
liRoot.textContent = modelId;
root.appendChild(liRoot);
if (objectIds.length) {
const ul = document.createElement("ul");
objectIds.slice(0, 2000).forEach((oid) => {
const li = document.createElement("li");
const span = document.createElement("span");
span.textContent = oid;
li.appendChild(span);
ul.appendChild(li);
});
liRoot.appendChild(ul);
}
treeContainer.appendChild(root);
customTrees.set(modelId, root);
};
const hasMetaModel = (modelId) => {
return !!viewer?.metaScene?.metaModels?.[modelId];
};
const destroyAllModels = ({ preserveMeta = false } = {}) => {
activeModels.forEach(({ model, loader }) => {
try {
model?.destroy && model.destroy();
} catch (err) {
console.warn("destroy model failed", err);
}
try {
loader?.destroy && loader.destroy();
} catch (err) {
console.warn("destroy loader failed", err);
}
});
activeModels.length = 0;
Object.keys(sceneModels).forEach((key) => delete sceneModels[key]);
Object.keys(modelTypes).forEach((key) => delete modelTypes[key]);
pointCloudColorSnapshots.clear();
if (!preserveMeta) {
Object.keys(transformStore).forEach((key) => delete transformStore[key]);
}
if (blobUrls.length) {
blobUrls.forEach((u) => {
try {
URL.revokeObjectURL(u);
} catch (_) {
// noop
}
});
blobUrls.length = 0;
}
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = null;
}
clearCustomTrees();
treeModelIds.clear();
try {
if (treeView?.destroy) {
treeView.destroy();
}
} catch (err) {
console.warn("treeView destroy failed", err);
}
treeView = null;
if (treeContainer?.replaceChildren) {
treeContainer.replaceChildren();
} else if (treeContainer) {
treeContainer.innerHTML = "";
}
if (sdkRef && viewer && treeContainer) {
resetTreeView(sdkRef);
}
// сбрасываем метамодели, чтобы не тянуть нейминг между проектами
if (viewer?.metaScene?.metaModels) {
try {
const ids = Object.keys(viewer.metaScene.metaModels);
ids.forEach((mid) => {
if (viewer.metaScene.destroyMetaModel) {
viewer.metaScene.destroyMetaModel(mid);
} else {
delete viewer.metaScene.metaModels[mid];
}
});
} catch (err) {
console.warn("metaScene cleanup failed", err);
}
}
setMeasureActive(false);
gizmo?.clearTarget();
treeView?.unShowNode?.();
clearMeasurements();
clearSectionPlanes();
selectionState = null;
lastSelection = { ids: [], modelId: null };
activeTransformTarget = null;
currentSelection = null;
if (!preserveMeta) {
currentProjectMeta = null;
lastAttemptedProjectId = null;
if (projectNameInput) {
projectNameInput.value = "";
projectNameInput.disabled = true;
}
}
updateNavCubeVisibility();
updatePanelsVisibility();
};
const getModelLabel = (modelId) => {
const entry = activeModels.find((m) => m?.model?.id === modelId);
if (!entry) return modelId;
return entry?.meta?.label || entry?.name || modelId;
};
const rebuildTreeModels = () => {
if (!viewer || !sdkRef) return;
treeModelIds.clear();
clearCustomTrees();
resetTreeView(sdkRef);
activeModels.forEach(({ model, name }) => {
const id = model?.id;
if (!id) return;
const metaPresent = hasMetaModel(id);
if (metaPresent && treeView?.addModel) {
try {
treeView.addModel(id, { rootName: name || id });
treeModelIds.add(id);
} catch (err) {
console.warn("tree addModel (rebuild)", err);
}
}
const hasNodes = treeContainer?.querySelector("li");
if (!metaPresent || !hasNodes) {
renderFallbackTree(id);
}
});
};
const deleteModelById = (modelId) => {
if (!modelId) return false;
const idx = activeModels.findIndex((m) => m?.model?.id === modelId);
if (idx === -1) return false;
const entry = activeModels[idx];
try {
entry.model?.destroy?.();
} catch (err) {
console.warn("destroy model failed", err);
}
try {
entry.loader?.destroy?.();
} catch (err) {
console.warn("destroy loader failed", err);
}
activeModels.splice(idx, 1);
delete sceneModels[modelId];
delete modelTypes[modelId];
pointCloudColorSnapshots.delete(modelId);
delete transformStore[modelId];
treeModelIds.delete(modelId);
if (customTrees.has(modelId)) {
try {
customTrees.get(modelId)?.remove?.();
} catch (_) {}
customTrees.delete(modelId);
}
if (viewer?.metaScene?.metaModels?.[modelId]) {
if (viewer.metaScene.destroyMetaModel) {
try {
viewer.metaScene.destroyMetaModel(modelId);
} catch (err) {
console.warn("meta destroy failed", err);
}
} else {
delete viewer.metaScene.metaModels[modelId];
}
}
if (selectionState?.modelId === modelId) {
selectionState = null;
updateTransformInputs(null);
updateGlobalInputs(null);
}
if (lastSelection?.modelId === modelId) {
lastSelection = { ids: [], modelId: null };
}
if (gizmo?.target?.modelId === modelId) {
gizmo.clearTarget();
}
activeTransformTarget = null;
currentSelection = null;
rebuildTreeModels();
updateNavCubeVisibility();
updatePanelsVisibility();
viewer?.scene?.setAABBDirty?.();
viewer?.scene?.glRedraw?.();
setStatus(`Модель ${modelId} удалена`);
return true;
};
const selectObject = ({ modelId, objectId }) => {
if (!viewer) return;
setSelection(objectId ? [objectId] : [], modelId);
if (objectId && treeView?.showNode) {
treeView.showNode(objectId);
}
};
const resetTreeView = (sdk) => {
if (treeView?.destroy) treeView.destroy();
treeView = new sdk.TreeViewPlugin(viewer, {
containerElement: treeContainer,
hierarchy: "containment",
autoAddModels: false,
pruneEmptyNodes: false,
autoExpandDepth: 2,
});
if (treeView?.on) {
treeView.on("nodeTitleClicked", (e) => {
const node = e?.treeViewNode || e?.node || e;
console.info("[DC Tree] nodeTitleClicked", {
nodeId: node?.nodeId || node?.id,
title: node?.title || node?.text || node?.name,
detail: e?.event?.detail
});
const ids = collectObjectIds(node);
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
setSelection(ids, modelId);
const clickDetail = (e?.event?.detail || e?.detail || 1);
if (clickDetail >= 2) {
flyToObjectIds(ids, modelId);
}
highlightTreeNode(node, { fromTree: true });
});
treeView.on("nodeTitleDoubleClicked", (e) => {
const node = e?.treeViewNode || e?.node || e;
console.info("[DC Tree] nodeTitleDoubleClicked", {
nodeId: node?.nodeId || node?.id,
title: node?.title || node?.text || node?.name
});
const ids = collectObjectIds(node);
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
highlightTreeNode(node, { fromTree: true });
flyToObjectIds(ids, modelId);
});
treeView.on("nodeDoubleClicked", (e) => {
const node = e?.treeViewNode || e?.node || e;
console.info("[DC Tree] nodeDoubleClicked", {
nodeId: node?.nodeId || node?.id,
title: node?.title || node?.text || node?.name
});
const ids = collectObjectIds(node);
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
highlightTreeNode(node, { fromTree: true });
flyToObjectIds(ids, modelId);
});
}
};
const createBlobSafeGLTFDataSource = () => ({
cacheBuster: false,
getGLTF: (src, ok, error) => {
if (src.startsWith("memory:") && memoryStore.has(src)) {
ok(memoryStore.get(src));
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getGLB: (src, ok, error) => {
if (src.startsWith("memory:") && memoryStore.has(src)) {
ok(memoryStore.get(src));
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getMetaModel: (src, ok, error) => {
if (src.startsWith("memory:") && memoryStore.has(src)) {
try {
const buffer = memoryStore.get(src);
const text = new TextDecoder().decode(new Uint8Array(buffer));
ok(JSON.parse(text));
} catch (err) {
error(err.message || err);
}
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(ok)
.catch((err) => error(err.message || err));
},
});
const createMemoryDataSource = () => ({
cacheBuster: false,
getXKT: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getGLTF: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getGLB: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getLAS: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getMetaModel: (src, ok, error) => {
if (memoryStore.has(src)) {
try {
const stored = memoryStore.get(src);
const bytes = cloneToUint8(stored) || new Uint8Array(0);
const text = new TextDecoder().decode(bytes);
ok(JSON.parse(text));
} catch (err) {
error(err.message || err);
}
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getSTL: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
});
const loaderFor = (sdk, type, { cacheBuster = true, dataSource } = {}) => {
switch (type) {
case "xkt":
return new sdk.XKTLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.XKTDefaultDataSource({ cacheBuster }),
});
case "gltf":
return new sdk.GLTFLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.GLTFDefaultDataSource({ cacheBuster }),
});
case "bim":
return new sdk.DotBIMLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.DotBIMDefaultDataSource({ cacheBuster }),
});
case "las":
return new sdk.LASLoaderPlugin(viewer, {
fp64: true,
dataSource: dataSource,
});
case "obj":
return new sdk.OBJLoaderPlugin(viewer);
case "stl":
return new sdk.STLLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.STLDefaultDataSource({ cacheBuster }),
});
default:
throw new Error(`Неподдерживаемый тип: ${type}`);
}
};
const guessTypeFromName = (name) => {
if (!name) return null;
const lower = name.toLowerCase();
if (lower.endsWith(".xkt")) return "xkt";
if (lower.endsWith(".glb") || lower.endsWith(".gltf")) return "gltf";
if (lower.endsWith(".bim")) return "bim";
if (lower.endsWith(".las") || lower.endsWith(".laz")) return "las";
if (lower.endsWith(".obj")) return "obj";
if (lower.endsWith(".stl")) return "stl";
return null;
};
const isApiRequestUrl = (url) => {
try {
return new URL(url, window.location.href).pathname.startsWith("/api/");
} catch (_) {
return String(url || "").startsWith("/api/");
}
};
const formatFetchFailureMessage = (url, res, text, data) => {
const apiRequest = isApiRequestUrl(url);
if (apiRequest && res.status === 401) {
return "BIM API не принял запрос: нужна авторизация.";
}
if (apiRequest && res.status === 404) {
return "BIM API недоступен на этом адресе. Открой viewer через node server/index.js, не через статический http-server.";
}
if (apiRequest && /^\s* {
const status = res?.status ? `HTTP ${res.status}` : "HTTP";
if (/^\s* {
const apiRequest = isApiRequestUrl(url);
let res = null;
try {
res = await fetch(url, options);
} catch (err) {
if (apiRequest) {
throw new Error(`BIM API недоступен: ${err?.message || "network error"}`);
}
throw err;
}
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (err) {
// ignore parse errors, text will be used for message
}
if (!res.ok) {
const message = formatFetchFailureMessage(url, res, text, data);
throw new Error(message);
}
if (apiRequest && text && !data) {
throw new Error(formatApiNonJsonMessage(url, res, text));
}
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, options = {}) => {
const safeName = filename || "upload.bin";
const params = new URLSearchParams({ filename: safeName });
const projectId = options.projectId || currentProjectMeta?.id;
if (projectId) params.set("projectId", projectId);
if (options.assetId) params.set("assetId", options.assetId);
if (options.version) params.set("version", String(options.version));
const res = await fetch(`/api/uploads?${params.toString()}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: bytes
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = null;
}
if (!res.ok) {
throw new Error((data && data.message) || text || `Upload failed (${res.status})`);
}
if (!data || !data.src) {
throw new Error("Upload response is missing src");
}
return data;
};
const getVersionKey = (version) => version?.versionId || `version-${version?.version || "unknown"}`;
const makeVersionModelId = (version) => {
versionModelCounter += 1;
const rawKey = version?.versionId || version?.version || version?.originalFilename || versionModelCounter;
const safeKey = String(rawKey).replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "") || "version";
return `model-version-${safeKey}-${versionModelCounter}`;
};
const getModelVersionIdentity = (entry = getPrimaryModelEntry()) => {
const meta = entry?.meta || {};
const direct = {
projectId: meta.projectId || meta.conversion?.projectId || null,
assetId: meta.assetId || meta.conversion?.assetId || null,
};
if (direct.projectId && direct.assetId) return direct;
const candidates = [
meta.sourceSrc,
meta.settingsSrc,
meta.artifactSrc,
meta.conversion?.sourceSrc,
meta.conversion?.artifactSrc,
entry?.src,
];
for (const candidate of candidates) {
const identity = extractUploadIdentityFromSrc(candidate);
if (identity.projectId && identity.assetId) return identity;
}
return direct;
};
const normalizeVersionSource = (value) => {
if (!value || typeof value !== "string") return "";
const normalized = value.trim();
if (normalized.startsWith("uploads/")) return `/${normalized}`;
try {
return new URL(normalized, window.location.href).toString();
} catch (_) {
return normalized;
}
};
const getVersionViewerSrc = (version) => {
if (!version?.previewAvailable) return "";
return normalizeVersionSource(version.src || version.viewerUrl || version.downloadUrl || "");
};
const getVersionSettingsSrc = (version) => (
version?.sourceSrc ||
version?.conversion?.sourceSrc ||
version?.downloadUrl ||
version?.src ||
""
);
const getVersionViewerType = (version) => {
const src = getVersionViewerSrc(version);
return normalizeType(version?.type, src) || normalizeType(guessTypeFromName(src || version?.originalFilename || ""));
};
const getLodKey = (lod) => lod?.id || lod?.artifactSrc || lod?.src || lod?.url || "lod";
const getLodViewerSrc = (lod) => normalizeVersionSource(lod?.src || lod?.url || lod?.artifactSrc || "");
const getLodViewerType = (lod) => {
const src = getLodViewerSrc(lod);
return normalizeType(lod?.type, src) || normalizeType(guessTypeFromName(src)) || "gltf";
};
const getEntryLods = (entry = getPrimaryModelEntry()) => {
const lods = entry?.meta?.lods;
return Array.isArray(lods) ? lods : [];
};
const getCurrentLodIdForEntry = (entry = getPrimaryModelEntry()) => {
const meta = entry?.meta || {};
const savedId = meta.selectedLodId ||
meta.selectedLod ||
meta.viewerSettings?.viewerState?.lod?.selectedId ||
meta.viewerSettings?.viewerState?.lod?.id ||
"";
if (savedId) return savedId;
const entrySrc = normalizeModelSettingsSrc(entry?.src || meta.artifactSrc || "");
const currentLod = getEntryLods(entry).find((lod) => {
const lodSrc = normalizeModelSettingsSrc(lod?.artifactSrc || lod?.src || lod?.url || "");
return lodSrc && lodSrc === entrySrc;
});
return currentLod?.id || getEntryLods(entry)[0]?.id || "";
};
const getSelectedLodRecord = (entry = getPrimaryModelEntry()) => {
const lods = getEntryLods(entry);
const currentId = getCurrentLodIdForEntry(entry);
return lods.find((lod) => currentId && lod?.id === currentId) || null;
};
const getSelectedLodViewerSrc = (entry = getPrimaryModelEntry()) => {
const lod = getSelectedLodRecord(entry);
return lod ? getLodViewerSrc(lod) : "";
};
const setEntryLodPayload = (entry, payload) => {
if (!entry || !payload) return;
const meta = entry.meta || {};
entry.meta = {
...meta,
sourceSrc: payload.sourceSrc || meta.sourceSrc || null,
settingsSrc: payload.sourceSrc || meta.settingsSrc || null,
artifactSrc: payload.artifactSrc || meta.artifactSrc || null,
artifactType: payload.artifactType || meta.artifactType || null,
xktConversion: payload.xktConversion || meta.xktConversion || null,
lodGeneration: payload.lodGeneration || null,
lods: Array.isArray(payload.lods) ? payload.lods : [],
selectedLod: payload.selectedLod || meta.selectedLod || null,
};
};
const isGlbLodCandidate = (entry = getPrimaryModelEntry()) => {
if (!entry) return false;
const meta = entry.meta || {};
const candidates = [
meta.sourceFormat,
meta.sourceSrc,
meta.settingsSrc,
meta.artifactSrc,
entry.src,
entry.name,
].filter(Boolean);
return candidates.some((value) => String(value).toLowerCase().includes(".glb") || String(value).toLowerCase() === "glb");
};
const buildLodParams = (entry = getPrimaryModelEntry()) => {
const params = new URLSearchParams();
const src = getModelSettingsSrc(entry) || entry?.meta?.sourceSrc || entry?.meta?.settingsSrc || "";
if (src) params.set("src", src);
return params;
};
const fetchModelLods = async (entry = getPrimaryModelEntry()) => {
const params = buildLodParams(entry);
if (!params.toString()) return null;
return fetchJSON(`${UPLOAD_LODS_API}?${params.toString()}`);
};
const formatLodOptionLabel = (lod) => {
const label = lod?.label || lod?.id || "LOD";
const size = formatFileSize(lod?.size);
return size ? `${label} · ${size}` : label;
};
const updateModelLodControl = () => {
const entry = getPrimaryModelEntry();
const lods = getEntryLods(entry);
const generation = entry?.meta?.lodGeneration || null;
const generationStatus = generation?.status || "";
const hasControl = !!entry && isGlbLodCandidate(entry) && (!isGuestMode() || lods.length > 0 || modelLodLoading);
modelLodControl?.classList.toggle("hidden", !hasControl);
if (!hasControl) {
modelLodGenerateButton?.classList.remove("is-generating");
return;
}
const hasReadyLods = lods.length > 0;
modelLodSelectHost?.classList.toggle("hidden", !hasReadyLods);
modelLodGenerateButton?.classList.toggle("hidden", hasReadyLods);
if (modelLodGenerateButton) {
const busy = modelLodGenerating || modelLodLoading || generationStatus === "queued" || generationStatus === "processing";
modelLodGenerateButton.classList.toggle("is-generating", busy);
modelLodGenerateButton.disabled = isGuestMode() || busy;
modelLodGenerateButton.textContent = busy
? "Генерируем LOD..."
: generationStatus === "failed"
? "Повторить генерацию LOD"
: "Сгенерировать LOD";
}
if (modelLodSelect) {
const options = hasReadyLods
? lods.map((lod) => ({ value: getLodKey(lod), label: formatLodOptionLabel(lod) }))
: [{ value: "loading", label: modelLodLoading ? "Загружаем LOD..." : "LOD не сгенерирован" }];
const currentValue = getCurrentLodIdForEntry(entry) || options[0]?.value;
modelLodSelect.setOptions(options, currentValue);
modelLodSelect.setDisabled(!hasReadyLods);
}
};
const scheduleModelLodPoll = () => {
if (modelLodPollTimer) {
window.clearTimeout(modelLodPollTimer);
}
modelLodPollTimer = window.setTimeout(() => {
modelLodPollTimer = null;
refreshModelLods({ silent: true }).catch(() => {});
}, 2500);
};
const refreshModelLods = async ({ silent = false } = {}) => {
const entry = getPrimaryModelEntry();
if (!entry || !isGlbLodCandidate(entry)) {
updateModelLodControl();
return null;
}
modelLodLoading = true;
updateModelLodControl();
try {
const payload = await fetchModelLods(entry);
setEntryLodPayload(entry, payload);
const status = payload?.lodGeneration?.status;
if (status === "queued" || status === "processing") {
scheduleModelLodPoll();
}
updateModelLodControl();
return payload;
} catch (err) {
if (!silent) console.warn("model LOD state unavailable", err);
updateModelLodControl();
return null;
} finally {
modelLodLoading = false;
updateModelLodControl();
}
};
const openModelLod = async (lod) => {
if (!loadModelAsyncRef) {
showError("Загрузчик модели еще не готов");
return;
}
const viewerSrc = getLodViewerSrc(lod);
const viewerType = getLodViewerType(lod);
if (!viewerSrc || !viewerType) {
setStatus("Этот LOD еще не готов к просмотру");
return;
}
const previousEntry = getPrimaryModelEntry();
const meta = {
...(previousEntry?.meta || {}),
artifactSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc,
selectedLod: lod.id || null,
selectedLodId: lod.id || null,
};
if (meta.viewerSettings?.viewerState) {
meta.viewerSettings = {
...meta.viewerSettings,
viewerState: {
...meta.viewerSettings.viewerState,
lod: {
selectedId: lod.id || null,
selectedSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc,
},
},
};
}
try {
await loadModelAsyncRef({
type: viewerType,
url: viewerSrc,
name: previousEntry?.name || meta.label || lod.label || "model",
replace: true,
preserveMeta: true,
id: previousEntry?.model?.id || "model",
meta,
});
updateModelLodControl();
setStatus(`Открыт LOD: ${lod.label || lod.id || "LOD"}`);
} catch (err) {
console.error(err);
showError(err.message || "Не удалось открыть LOD модели");
updateModelLodControl();
}
};
const generateModelLods = async () => {
const entry = getPrimaryModelEntry();
if (!entry || isGuestMode() || modelLodGenerating) return;
const settingsSrc = getModelSettingsSrc(entry);
if (!settingsSrc) {
setStatus("LOD можно сгенерировать только для модели из BIM-хранилища");
return;
}
modelLodGenerating = true;
updateModelLodControl();
try {
const payload = await fetchJSON(UPLOAD_LODS_API, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
settingsSrc,
sourceSrc: entry.meta?.sourceSrc || settingsSrc,
artifactSrc: entry.meta?.artifactSrc || entry.src || null,
}),
});
setEntryLodPayload(entry, payload);
scheduleModelLodPoll();
setStatus("Генерация LOD поставлена в очередь");
} catch (err) {
console.error(err);
showError(err.message || "Не удалось запустить генерацию LOD");
} finally {
modelLodGenerating = false;
updateModelLodControl();
}
};
const getCurrentVersionIdForEntry = (entry = getPrimaryModelEntry()) => {
const meta = entry?.meta || {};
return meta.versionId || meta.conversion?.versionId || modelVersionHistory?.currentVersionId || null;
};
const isCurrentVersionRecord = (version, entry = getPrimaryModelEntry()) => {
const currentVersionId = getCurrentVersionIdForEntry(entry);
if (currentVersionId && version?.versionId) return currentVersionId === version.versionId;
const currentVersion = entry?.meta?.version || modelVersionHistory?.currentVersion;
return Number(currentVersion) === Number(version?.version);
};
const formatVersionOptionLabel = (version) => {
const number = Number(version?.version);
const prefix = Number.isFinite(number) ? `v${number}` : "Версия";
const name = version?.originalFilename || "model";
return `${prefix} · ${name}`;
};
const buildVersionHistoryParams = (entry = getPrimaryModelEntry()) => {
const identity = getModelVersionIdentity(entry);
const params = new URLSearchParams();
if (identity.projectId && identity.assetId) {
params.set("projectId", identity.projectId);
params.set("assetId", identity.assetId);
return params;
}
const src = getModelSettingsSrc(entry) || entry?.meta?.sourceSrc || entry?.meta?.artifactSrc || entry?.src || "";
if (src) params.set("src", src);
return params;
};
const setEntryVersionHistory = (entry, history) => {
if (!entry || !history) return;
const versions = Array.isArray(history.versions) ? history.versions : [];
const meta = entry.meta || {};
entry.meta = {
...meta,
assetId: history.assetId || meta.assetId || null,
projectId: history.projectId || meta.projectId || null,
versionId: meta.versionId || history.currentVersionId || null,
version: meta.version || history.currentVersion || null,
versions,
};
};
const fetchVersionHistoryForEntry = async (entry = getPrimaryModelEntry()) => {
const params = buildVersionHistoryParams(entry);
if (!params.toString()) return null;
return fetchJSON(`${UPLOAD_VERSIONS_API}?${params.toString()}`);
};
const updateModelVersionControl = () => {
const entry = getPrimaryModelEntry();
const versions = modelVersionHistory?.versions || entry?.meta?.versions || [];
const identity = getModelVersionIdentity(entry);
const hasControl = !!entry && (!!identity.assetId || versions.length > 0);
modelVersionControl?.classList.toggle("hidden", !hasControl);
if (!hasControl) return;
const currentVersionId = getCurrentVersionIdForEntry(entry);
const currentRecord = versions.find((version) => currentVersionId && version?.versionId === currentVersionId) ||
versions.find((version) => Number(version?.version) === Number(entry?.meta?.version)) ||
versions[versions.length - 1];
const currentValue = currentRecord ? getVersionKey(currentRecord) :
currentVersionId ||
"";
const options = versions.length
? versions.map((version) => ({ value: getVersionKey(version), label: formatVersionOptionLabel(version) }))
: [{ value: "loading", label: modelVersionHistoryLoading ? "Загружаем версии..." : "Версии не найдены" }];
if (modelVersionSelect) {
modelVersionSelect.setOptions(options, currentValue || options[0]?.value);
}
if (modelVersionHistoryButton) {
modelVersionHistoryButton.disabled = modelVersionHistoryLoading || !versions.length;
}
if (modelVersionUploadButton) {
modelVersionUploadButton.disabled = isGuestMode() || modelVersionUploading || !identity.assetId || !identity.projectId;
modelVersionUploadButton.title = modelVersionUploading ? "Загружаем версию..." : "Добавить версию";
}
};
const refreshModelVersionHistory = async ({ silent = false } = {}) => {
const entry = getPrimaryModelEntry();
if (!entry) {
modelVersionHistory = null;
updateModelVersionControl();
return null;
}
const params = buildVersionHistoryParams(entry);
if (!params.toString()) {
updateModelVersionControl();
return null;
}
modelVersionHistoryLoading = true;
updateModelVersionControl();
try {
const history = await fetchVersionHistoryForEntry(entry);
modelVersionHistory = history;
setEntryVersionHistory(entry, history);
updateModelVersionControl();
return history;
} catch (err) {
if (!silent) console.warn("model version history unavailable", err);
updateModelVersionControl();
return null;
} finally {
modelVersionHistoryLoading = false;
updateModelVersionControl();
}
};
const openModelVersion = async (version) => {
if (!loadModelAsyncRef) {
showError("Загрузчик модели еще не готов");
return;
}
const viewerSrc = getVersionViewerSrc(version);
const viewerType = getVersionViewerType(version);
if (!viewerSrc || !viewerType) {
setStatus("Эта версия еще не готова к просмотру");
return;
}
const previousEntry = getPrimaryModelEntry();
const identity = getModelVersionIdentity(previousEntry);
const settingsSrc = getVersionSettingsSrc(version);
const meta = {
...(previousEntry?.meta || {}),
assetId: version.assetId || identity.assetId || previousEntry?.meta?.assetId || null,
projectId: version.projectId || identity.projectId || previousEntry?.meta?.projectId || null,
version: version.version || null,
versionId: version.versionId || null,
versions: modelVersionHistory?.versions || previousEntry?.meta?.versions || [],
label: version.originalFilename || previousEntry?.name || "model",
sourceSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc,
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc,
artifactSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc,
artifactType: viewerType,
xktConversion: version.xktConversion || null,
viewerSettings: null,
};
try {
const settings = await fetchModelSettings(settingsSrc).catch(() => null);
if (settings?.viewerSettings) {
meta.viewerSettings = settings.viewerSettings;
}
if (settings) {
meta.lodGeneration = settings.lodGeneration || null;
meta.lods = Array.isArray(settings.lods) ? settings.lods : [];
meta.selectedLod = settings.selectedLod || null;
meta.artifactSrc = settings.artifactSrc || meta.artifactSrc;
meta.artifactType = normalizeType(null, settings.artifactSrc) || meta.artifactType || viewerType;
meta.xktConversion = settings.xktConversion || meta.xktConversion || null;
}
const previousModelId = previousEntry?.model?.id || null;
const nextModelId = makeVersionModelId(version);
if (previousModelId && transformStore[previousModelId]) {
transformStore[nextModelId] = safeClone(transformStore[previousModelId]);
}
await loadModelAsyncRef({
type: viewerType,
url: viewerSrc,
name: version.originalFilename || previousEntry?.name || "model",
replace: true,
preserveMeta: true,
id: nextModelId,
meta,
});
modelVersionHistory = {
...(modelVersionHistory || {}),
currentVersion: version.version || modelVersionHistory?.currentVersion || null,
currentVersionId: version.versionId || modelVersionHistory?.currentVersionId || null,
versions: modelVersionHistory?.versions || meta.versions || [],
};
updateModelVersionControl();
updateModelLodControl();
setStatus(`Открыта версия v${version.version || ""}`.trim());
} catch (err) {
console.error(err);
showError(err.message || "Не удалось открыть версию модели");
updateModelVersionControl();
}
};
const uploadModelVersion = async (file) => {
const entry = getPrimaryModelEntry();
const identity = getModelVersionIdentity(entry);
if (!file || !entry || !identity.projectId || !identity.assetId || isGuestMode()) return;
const detected = guessTypeFromName(file.name);
const uploadOnly = /\.stp?$/i.test(file.name);
if (!detected && !uploadOnly) {
showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl, .step или .stp");
return;
}
const versions = modelVersionHistory?.versions || entry.meta?.versions || [];
const nextVersion = Math.max(0, ...versions.map((version) => Number(version?.version) || 0), Number(entry.meta?.version) || 0) + 1;
modelVersionUploading = true;
updateModelVersionControl();
try {
const bytes = cloneToUint8(await file.arrayBuffer()) || new Uint8Array(0);
const uploaded = await uploadBuffer(bytes, file.name, {
projectId: identity.projectId,
assetId: identity.assetId,
version: nextVersion,
});
uploadCache.set(entry.src || file.name, uploaded.src);
const history = await refreshModelVersionHistory({ silent: true });
const newVersion = history?.versions?.find((version) => version.versionId === uploaded.versionId) ||
history?.versions?.find((version) => Number(version.version) === Number(uploaded.version));
if (newVersion?.previewAvailable) {
await openModelVersion(newVersion);
} else {
setStatus(`Версия v${uploaded.version || nextVersion} загружена. Просмотр появится после подготовки модели.`);
}
} catch (err) {
console.error(err);
showError(err.message || "Не удалось загрузить версию модели");
} finally {
modelVersionUploading = false;
updateModelVersionControl();
}
};
const closeVersionHistoryModal = () => {
versionHistoryModal?.backdrop?.remove();
versionHistoryModal?.modal?.remove();
versionHistoryModal = null;
};
const renderVersionHistoryModal = () => {
if (!versionHistoryModal) return;
const entry = getPrimaryModelEntry();
const versions = modelVersionHistory?.versions || entry?.meta?.versions || [];
const { body, title, subtitle } = versionHistoryModal;
title.textContent = "История версий";
subtitle.textContent = entry?.name || entry?.meta?.label || modelVersionHistory?.originalFilename || "model";
body.replaceChildren();
if (!versions.length) {
const empty = document.createElement("div");
empty.className = "version-history-empty";
empty.textContent = modelVersionHistoryLoading ? "Загружаем версии..." : "История версий пока не создана.";
body.appendChild(empty);
return;
}
const table = document.createElement("div");
table.className = "version-history-table";
const header = document.createElement("div");
header.className = "version-history-row version-history-row--head";
["Файл", "Версия", "Размер", "Автор", "Дата", "Статус", "Действия"].forEach((label) => {
const cell = document.createElement("div");
cell.textContent = label;
if (label === "Действия") cell.style.textAlign = "right";
header.appendChild(cell);
});
table.appendChild(header);
[...versions].sort((a, b) => Number(b.version || 0) - Number(a.version || 0)).forEach((version) => {
const row = document.createElement("div");
row.className = "version-history-row";
const fileCell = document.createElement("div");
fileCell.className = "version-history-file";
const fileName = document.createElement("strong");
fileName.textContent = version.originalFilename || "model";
fileCell.appendChild(fileName);
if (version.sha256) {
const sha = document.createElement("span");
sha.textContent = `sha256 ${version.sha256}`;
fileCell.appendChild(sha);
}
row.appendChild(fileCell);
const versionCell = document.createElement("div");
versionCell.className = "version-history-cell";
versionCell.textContent = Number.isFinite(Number(version.version)) ? `v${version.version}` : "—";
if (isCurrentVersionRecord(version, entry)) {
const current = document.createElement("span");
current.className = "version-history-current";
current.textContent = "текущая";
versionCell.appendChild(current);
}
row.appendChild(versionCell);
const sizeCell = document.createElement("div");
sizeCell.className = "version-history-cell";
sizeCell.textContent = formatFileSize(version.size) || "—";
row.appendChild(sizeCell);
const authorCell = document.createElement("div");
authorCell.className = "version-history-cell";
authorCell.textContent = version.uploadedBy || "—";
row.appendChild(authorCell);
const dateCell = document.createElement("div");
dateCell.className = "version-history-cell";
dateCell.textContent = formatDateTime(version.uploadedAt);
row.appendChild(dateCell);
const statusCell = document.createElement("div");
statusCell.className = "version-history-cell";
statusCell.textContent = version.status === "ready"
? "Модель готова"
: version.status === "failed"
? "Ошибка подготовки"
: "Ожидает подготовки";
row.appendChild(statusCell);
const actions = document.createElement("div");
actions.className = "version-history-actions";
const viewButton = document.createElement("button");
viewButton.type = "button";
viewButton.className = "inspector-icon-btn";
viewButton.title = "Посмотреть";
viewButton.innerHTML = ``;
viewButton.disabled = !getVersionViewerSrc(version);
viewButton.addEventListener("click", () => {
closeVersionHistoryModal();
openModelVersion(version);
});
actions.appendChild(viewButton);
if (version.downloadUrl) {
const download = document.createElement("a");
download.className = "inspector-icon-btn";
download.href = version.downloadUrl;
download.target = "_blank";
download.rel = "noopener noreferrer";
download.download = version.originalFilename || "model";
download.title = "Скачать";
download.innerHTML = ``;
actions.appendChild(download);
}
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "inspector-icon-btn";
deleteButton.title = "Удалить версию";
deleteButton.innerHTML = ``;
deleteButton.disabled = isGuestMode() || isCurrentVersionRecord(version, entry);
deleteButton.addEventListener("click", async () => {
deleteButton.disabled = true;
try {
await fetchJSON("/api/uploads/version", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(version),
});
await refreshModelVersionHistory({ silent: true });
renderVersionHistoryModal();
} catch (err) {
showError(err.message || "Не удалось удалить версию");
}
});
actions.appendChild(deleteButton);
row.appendChild(actions);
table.appendChild(row);
});
body.appendChild(table);
};
const openVersionHistoryModal = async () => {
if (versionHistoryModal) {
renderVersionHistoryModal();
return;
}
const backdrop = document.createElement("div");
backdrop.className = "version-history-backdrop";
const modal = document.createElement("div");
modal.className = "version-history-modal";
const header = document.createElement("div");
header.className = "version-history-header";
const titleWrap = document.createElement("div");
titleWrap.className = "version-history-title";
const title = document.createElement("strong");
const subtitle = document.createElement("span");
titleWrap.append(title, subtitle);
const close = document.createElement("button");
close.type = "button";
close.className = "inspector-icon-btn version-history-close";
close.title = "Закрыть";
close.innerHTML = ``;
header.append(titleWrap, close);
const body = document.createElement("div");
body.className = "version-history-body";
modal.append(header, body);
document.body.append(backdrop, modal);
versionHistoryModal = { backdrop, modal, title, subtitle, body };
close.addEventListener("click", closeVersionHistoryModal);
backdrop.addEventListener("click", closeVersionHistoryModal);
renderVersionHistoryModal();
await refreshModelVersionHistory({ silent: true });
renderVersionHistoryModal();
};
const loadProjectsList = async () => {
try {
const list = await fetchJSON(API_BASE);
const templates = Array.isArray(list) ? list.filter((item) => item.type === "template") : [];
const projects = Array.isArray(list) ? list.filter((item) => item.type !== "template") : [];
projectIndex = { templates, projects };
templatesMenu?.setData(projectIndex);
return projectIndex;
} catch (err) {
const fallback = { templates: templateFallbacks, projects: [] };
projectIndex = fallback;
templatesMenu?.setData({ ...fallback, error: err.message || "API недоступно" });
throw err;
}
};
const fetchProjectById = async (id) => {
if (!id) {
throw new Error("Не указан ID проекта");
}
return fetchJSON(`${API_BASE}/${id}`);
};
const findProjectMetaById = (id) => {
if (!id) return null;
const all = [...(projectIndex.templates || []), ...(projectIndex.projects || [])];
const found = all.find((p) => p.id === id);
if (found) {
return { id: found.id, name: found.name, type: found.type };
}
return null;
};
const submitProjectRename = async () => {
if (isGuestMode()) return;
if (!projectNameInput || projectNameInput.disabled) return;
const newName = (projectNameInput.value || "").trim();
const targetId = currentProjectMeta?.id || lastAttemptedProjectId;
if (!targetId || !newName) {
if (!newName && projectNameError) projectNameError.textContent = "";
return;
}
if (projectNameError) projectNameError.textContent = "";
try {
const updated = await fetchJSON(`${API_BASE}/${targetId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName })
});
currentProjectMeta = { ...(currentProjectMeta || {}), id: targetId, name: updated.name || newName, type: updated.type || currentProjectMeta?.type || "project" };
lastAttemptedProjectId = currentProjectMeta.id;
await loadProjectsList().catch(() => {});
setStatus("Название проекта обновлено");
} catch (err) {
console.error(err);
if (projectNameError) projectNameError.textContent = err.message || "Не удалось обновить название";
}
};
const safeClone = (value) => {
try {
return JSON.parse(JSON.stringify(value || {}));
} catch (err) {
return {};
}
};
const isRemoteSrc = (src) => {
if (!src || typeof src !== "string") return false;
return !(src.startsWith("memory:") || src.startsWith("blob:"));
};
const cloneToUint8 = (data) => {
if (!data) return new Uint8Array(0);
if (data instanceof Uint8Array) {
return data.slice();
}
if (data instanceof ArrayBuffer) {
try {
return new Uint8Array(data.slice(0));
} catch (err) {
return new Uint8Array(0);
}
}
try {
return new Uint8Array(data);
} catch (err) {
return new Uint8Array(0);
}
};
const arrayBufferToBase64 = (buffer) => {
const bytes = cloneToUint8(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
};
const getBufferForSrc = async (src) => {
if (!src) return null;
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const bytes = cloneToUint8(stored);
return bytes;
}
try {
const res = await fetch(src);
if (!res.ok) return null;
const buf = await res.arrayBuffer();
return cloneToUint8(buf);
} catch (err) {
return null;
}
};
const snapshotVec = (v) => {
if (!v) return null;
if (Array.isArray(v)) return [...v];
if (ArrayBuffer.isView(v)) return Array.from(v);
return null;
};
const buildActiveTransformsSnapshot = () => {
const transformsCopy = safeClone(transformStore);
activeModels.forEach(({ model }) => {
if (!model || !model.id) return;
if (!transformsCopy[model.id]) transformsCopy[model.id] = {};
if (!transformsCopy[model.id]["__model__"]) {
transformsCopy[model.id]["__model__"] = {
position: snapshotVec(model.position) || [0, 0, 0],
rotation: snapshotVec(model.rotation) || [0, 0, 0],
scale: snapshotVec(model.scale) || [1, 1, 1],
};
}
});
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;
const src = typeof entry.src === "string" ? entry.src : "";
const meta = entry.meta || {};
const preferredSrc = normalizeModelSettingsSrc(getSelectedLodViewerSrc(entry) || meta.artifactSrc || src) || src;
const modelType = normalizeType(null, preferredSrc) || normalizeType(entry.type, src) || normalizeType(guessTypeFromName(src)) || entry.type || null;
const versionIdentity = getModelVersionIdentity(entry);
const baseModel = {
id: model.id,
type: modelType,
meta: {
label: meta.label || entry.name || model.id,
assetId: meta.assetId || meta.conversion?.assetId || versionIdentity.assetId || null,
projectId: meta.projectId || meta.conversion?.projectId || versionIdentity.projectId || null,
version: meta.version || null,
versionId: meta.versionId || meta.conversion?.versionId || null,
versions: Array.isArray(meta.versions) ? meta.versions : [],
metadataPath: meta.metadataPath || null,
settingsSrc: getModelSettingsSrc(entry) || null,
sourceSrc: meta.sourceSrc || meta.conversion?.sourceSrc || meta.manifest?.sourceSrc || null,
artifactSrc: meta.artifactSrc || null,
artifactType: meta.artifactType || normalizeType(null, meta.artifactSrc) || null,
xktConversion: meta.xktConversion || null,
lodGeneration: meta.lodGeneration || null,
lods: Array.isArray(meta.lods) ? meta.lods : [],
selectedLod: getCurrentLodIdForEntry(entry) || meta.selectedLod || null
},
visible: model.visible !== false
};
if (!modelType) {
throw new Error("Не удалось определить тип модели для сохранения.");
}
if (isRemoteSrc(preferredSrc)) {
models.push({ ...baseModel, src: preferredSrc });
continue;
}
const cacheKey = preferredSrc || src || `${model.id}-${modelType}`;
if (uploadCache.has(cacheKey)) {
models.push({ ...baseModel, src: uploadCache.get(cacheKey) });
continue;
}
const buffer = await getBufferForSrc(src);
if (!buffer) {
throw new Error("Нельзя сохранить проект: отсутствуют данные загруженного файла (blob/memory).");
}
const bytes = cloneToUint8(buffer);
const filename = (entry.name && entry.name.includes(".")) ? entry.name : `${model.id || "model"}.${modelType || "bin"}`;
const uploaded = await uploadBuffer(bytes, filename);
const uploadedSrc = uploaded.src;
uploadCache.set(cacheKey, uploadedSrc);
models.push({
...baseModel,
src: uploadedSrc,
meta: {
...baseModel.meta,
assetId: uploaded.assetId || baseModel.meta.assetId || null,
projectId: uploaded.projectId || baseModel.meta.projectId || null,
version: uploaded.version || baseModel.meta.version || null,
versionId: uploaded.versionId || baseModel.meta.versionId || null,
sourceSrc: uploaded.src || baseModel.meta.sourceSrc || null,
settingsSrc: uploaded.src || baseModel.meta.settingsSrc || null,
}
});
}
const viewerState = viewer ? {
camera: viewer.camera ? {
eye: snapshotVec(viewer.camera.eye),
look: snapshotVec(viewer.camera.look),
up: snapshotVec(viewer.camera.up),
projection: viewer.camera.projection
} : null,
transparent: !!toggleTransparent?.checked,
edges: !!toggleEdges?.checked,
addMode: !!addMode?.checked,
displayMode: activeDisplayMode,
customDisplayColor: activeCustomDisplayColor,
customDisplaySaturation: activeCustomDisplaySaturation,
lightingMode: activeLightingMode,
navigationAxis: activeNavigationAxis,
zoomSpeed: activeZoomSpeed,
selection: {
ids: lastSelection.ids || [],
modelId: lastSelection.modelId || null,
},
models: buildActiveModelStateSnapshot(),
transforms: transformsCopy
} : {};
const design = settingsMenu?.getState ? settingsMenu.getState() : {};
return {
name,
type: "project",
models,
viewerState,
design,
meta: {
description: "",
tags: []
}
};
};
const applyDesignState = (design) => {
if (!design || typeof design !== "object") return;
if (!settingsMenu?.applyState) {
pendingDesignState = { ...design };
return;
}
settingsMenu.applyState(design);
applyToolbarVars(settingsMenu?.getState?.());
updateToolbarDockMode();
applyHighlightTheme();
applyMeasureTheme();
if (sdkRef) {
rebuildNavCube(sdkRef);
}
};
const applyCameraSnapshot = (cameraState) => {
if (!viewer?.camera || !cameraState || typeof cameraState !== "object") return;
const { eye, look, up, projection } = cameraState;
if (Array.isArray(eye) && eye.length === 3) viewer.camera.eye = [...eye];
if (Array.isArray(look) && look.length === 3) viewer.camera.look = [...look];
if (Array.isArray(up) && up.length === 3) viewer.camera.up = [...up];
if (projection) viewer.camera.projection = projection;
updateProjectionButton();
viewer.scene?.glRedraw?.();
};
const buildModelViewerSettings = () => ({
version: 1,
updatedAt: new Date().toISOString(),
viewerState: viewer ? {
camera: viewer.camera ? {
eye: snapshotVec(viewer.camera.eye),
look: snapshotVec(viewer.camera.look),
up: snapshotVec(viewer.camera.up),
projection: viewer.camera.projection
} : null,
transparent: !!toggleTransparent?.checked,
edges: !!toggleEdges?.checked,
displayMode: activeDisplayMode,
customDisplayColor: activeCustomDisplayColor,
customDisplaySaturation: activeCustomDisplaySaturation,
lightingMode: activeLightingMode,
navigationAxis: activeNavigationAxis,
zoomSpeed: activeZoomSpeed,
models: buildActiveModelStateSnapshot(),
transforms: buildActiveTransformsSnapshot(),
lod: (() => {
const entry = getPrimaryModelEntry();
const selected = getSelectedLodRecord(entry);
const selectedSrc = getSelectedLodViewerSrc(entry);
return {
selectedId: selected?.id || getCurrentLodIdForEntry(entry) || null,
selectedSrc: normalizeModelSettingsSrc(selectedSrc) || selectedSrc || null,
levels: getEntryLods(entry).map((lod) => ({
id: lod.id || null,
label: lod.label || null,
artifactSrc: normalizeModelSettingsSrc(lod.artifactSrc || lod.src || lod.url || "") || null,
size: Number(lod.size) || 0
}))
};
})()
} : {},
design: settingsMenu?.getState ? settingsMenu.getState() : {}
});
const applyModelViewerSettings = (settings, { applyCamera = true, applyDesign = true } = {}) => {
if (!settings || typeof settings !== "object") return;
const viewerState = settings.viewerState && typeof settings.viewerState === "object"
? settings.viewerState
: {};
if (applyDesign && settings.design && typeof settings.design === "object") {
applyDesignState(settings.design);
}
if (!viewer) return;
if (typeof viewerState.transparent === "boolean" && toggleTransparent) {
toggleTransparent.checked = viewerState.transparent;
viewer.transparent = viewerState.transparent;
if (viewer.scene?.canvas) {
viewer.scene.canvas.transparent = viewerState.transparent;
}
}
if (typeof viewerState.edges === "boolean" && toggleEdges) {
suppressEdgeReload = true;
toggleEdges.checked = viewerState.edges;
suppressEdgeReload = false;
}
setCustomDisplaySettings({
color: viewerState.customDisplayColor,
saturation: viewerState.customDisplaySaturation,
});
if (typeof viewerState.displayMode === "string") {
activeDisplayMode = viewerState.displayMode;
applyDisplayMode();
} else if (viewerState.customDisplayColor !== undefined || viewerState.customDisplaySaturation !== undefined) {
setCustomDisplayControlsState();
if (normalizeDisplayMode(activeDisplayMode) === "custom") {
applyDisplayMode();
}
}
if (typeof viewerState.lightingMode === "string") {
activeLightingMode = viewerState.lightingMode;
applyLightingMode();
}
if (typeof viewerState.navigationAxis === "string") {
activeNavigationAxis = viewerState.navigationAxis;
applyNavigationAxis({ resetAuto: false });
}
if (viewerState.zoomSpeed !== undefined) {
setZoomSpeed(viewerState.zoomSpeed);
}
if (applyCamera) {
applyCameraSnapshot(viewerState.camera);
}
rearmCameraControl();
};
const fetchModelSettings = async (src) => {
const normalized = normalizeModelSettingsSrc(src);
if (!normalized) return null;
const params = new URLSearchParams({ src: normalized });
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,
lodCount: Array.isArray(data?.lods) ? data.lods.length : 0,
lodStatus: data?.lodGeneration?.status || null,
});
return data;
};
const saveCurrentModelSettings = async () => {
if (isGuestMode()) {
setStatus("Гостевой просмотр не сохраняет изменения");
return;
}
const entry = getPrimaryModelEntry();
const settingsSrc = getModelSettingsSrc(entry);
if (!entry || !settingsSrc) {
setStatus("Настройки можно сохранить только для модели из Beam-хранилища");
return;
}
const previousLabel = saveModelSettingsButton?.textContent || "Сохранить настройки";
if (saveModelSettingsButton) {
saveModelSettingsButton.disabled = true;
saveModelSettingsButton.textContent = "Сохраняем...";
}
try {
const payload = { viewerSettings: buildModelViewerSettings() };
const params = new URLSearchParams({ src: settingsSrc });
const saved = await fetchJSON(`${MODEL_SETTINGS_API}?${params.toString()}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
entry.meta = {
...(entry.meta || {}),
settingsSrc: saved?.sourceSrc || settingsSrc,
sourceSrc: saved?.sourceSrc || entry.meta?.sourceSrc || null,
lodGeneration: saved?.lodGeneration || entry.meta?.lodGeneration || null,
lods: Array.isArray(saved?.lods) ? saved.lods : (entry.meta?.lods || []),
selectedLod: saved?.selectedLod || entry.meta?.selectedLod || null,
viewerSettings: saved?.viewerSettings || payload.viewerSettings
};
updateModelLodControl();
setStatus("Настройки модели сохранены");
log(`Настройки модели сохранены: ${entry.name || entry.model?.id || settingsSrc}`, false);
} catch (err) {
console.error(err);
showError(err.message || "Не удалось сохранить настройки модели");
} finally {
if (saveModelSettingsButton) {
saveModelSettingsButton.textContent = previousLabel;
}
updatePanelsVisibility();
}
};
const applyViewerState = (state) => {
if (!viewer || !state || typeof state !== "object") return;
if (state.camera && viewer.camera) {
applyCameraSnapshot(state.camera);
}
if (typeof state.transparent === "boolean" && toggleTransparent) {
toggleTransparent.checked = state.transparent;
toggleTransparent.dispatchEvent(new Event("change"));
}
if (typeof state.edges === "boolean" && toggleEdges) {
suppressEdgeReload = true;
toggleEdges.checked = state.edges;
toggleEdges.dispatchEvent(new Event("change"));
suppressEdgeReload = false;
}
if (typeof state.addMode === "boolean" && addMode) {
addMode.checked = state.addMode;
}
setCustomDisplaySettings({
color: state.customDisplayColor,
saturation: state.customDisplaySaturation,
});
if (typeof state.displayMode === "string") {
activeDisplayMode = state.displayMode;
applyDisplayMode();
} else if (state.customDisplayColor !== undefined || state.customDisplaySaturation !== undefined) {
setCustomDisplayControlsState();
if (normalizeDisplayMode(activeDisplayMode) === "custom") {
applyDisplayMode();
}
}
if (typeof state.lightingMode === "string") {
activeLightingMode = state.lightingMode;
applyLightingMode();
}
if (typeof state.navigationAxis === "string") {
activeNavigationAxis = state.navigationAxis;
applyNavigationAxis({ resetAuto: false });
}
if (state.zoomSpeed !== undefined) {
setZoomSpeed(state.zoomSpeed);
}
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) => {
ensureLoadedModelTransformAlias(mid, { viewerState: state });
applyStoredTransformsToModel(mid);
});
}
if (Array.isArray(state.models)) {
state.models.forEach((m) => {
if (!m || !m.id) return;
const model = sceneModels[m.id];
if (model && typeof m.visible === "boolean") {
model.visible = m.visible;
}
});
}
if (state.selection) {
const ids = Array.isArray(state.selection.ids) ? state.selection.ids : [];
const filtered = ids.filter((id) => viewer.scene?.objects?.[id]);
if (filtered.length) {
setSelection(filtered, state.selection.modelId || null);
} else {
selectionState = null;
gizmo?.clearTarget?.();
updateTransformInputs(null);
updateGlobalInputs(null);
}
}
if (deleteProjectButton) {
deleteProjectButton.disabled = !currentProjectMeta;
}
if (projectNameInput) {
projectNameInput.value = currentProjectMeta?.name || state.name || "";
projectNameInput.disabled = !(currentProjectMeta || lastAttemptedProjectId);
}
rearmCameraControl();
};
const initViewer = async () => {
await initializeRuntimeMode();
setupNodeDCSessionSync();
setupNodeDCSessionWatcher();
const sdk = await import(SDK_URL);
sdkRef = sdk;
const { Viewer, NavCubePlugin, DistanceMeasurementsPlugin, DistanceMeasurementsMouseControl, SectionPlanesPlugin } = sdk;
mathUtil = sdk.math;
if (!embeddedViewer && headerEl && homeButton && !homeButton.querySelector(".ue-logo")) {
const logoEl = createLogo();
homeButton.appendChild(logoEl);
homeButton.title = "Открыть NODE.DC Hub";
homeButton.setAttribute("aria-label", "Открыть NODE.DC Hub");
if (logoEl.style.width) homeButton.style.width = logoEl.style.width;
if (logoEl.style.height) homeButton.style.height = logoEl.style.height;
homeButton.style.border = "none";
homeButton.style.background = "transparent";
homeButton.style.padding = "0";
}
// Отключаем cacheBuster для glTF/GLB повсюду, чтобы не ломать blob: URL
if (sdk.GLTFDefaultDataSource) {
sdk.GLTFDefaultDataSource.prototype._cacheBusterURL = function (url) {
return url;
};
}
// Для blob: используем fetch вместо XHR внутри sdk.utils.loadArraybuffer
if (sdk.utils && typeof sdk.utils.loadArraybuffer === "function") {
const originalLoadArraybuffer = sdk.utils.loadArraybuffer;
sdk.utils.loadArraybuffer = (url, ok, error) => {
if (typeof url === "string" && url.startsWith("memory:") && memoryStore.has(url)) {
ok(memoryStore.get(url));
return;
}
if (typeof url === "string" && url.startsWith("blob:")) {
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
} else {
originalLoadArraybuffer(url, ok, error);
}
};
}
if (sdk.LASDefaultDataSource) {
sdk.LASDefaultDataSource.prototype._cacheBusterURL = function (url) {
return url;
};
}
const viewerContextAttr = {};
await waitForViewerCanvasReady(viewerContextAttr);
viewer = new Viewer({
canvasId: "viewerCanvas",
transparent: true,
contextAttr: viewerContextAttr,
dtxEnabled: true,
colorTextureEnabled: true,
});
configureCameraControl();
defaultCameraWorldAxis = snapshotVec(viewer.camera?.worldAxis) || [...NAVIGATION_AXIS_PRESETS["y-up"].worldAxis];
defaultCameraUp = snapshotVec(viewer.camera?.up) || [...NAVIGATION_AXIS_PRESETS["y-up"].up];
if (viewer.scene?.highlightMaterial) {
const hexToRGB = (hex) => {
const h = hex.replace("#", "");
const bigint = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return [r / 255, g / 255, b / 255];
};
const hl = getComputedStyle(document.documentElement).getPropertyValue("--highlight-color").trim() || "#b833ff";
const color = hexToRGB(hl);
viewer.scene.highlightMaterial.color = color;
if (viewer.scene.highlightMaterial.edgeColor) {
viewer.scene.highlightMaterial.edgeColor = color;
}
if (viewer.scene.highlightMaterial.fillColor) {
viewer.scene.highlightMaterial.fillColor = [...color, 0.5];
}
}
if (SectionPlanesPlugin && sectionOverviewCanvas) {
sectionPlanesPlugin = new SectionPlanesPlugin(viewer, {
overviewCanvasId: "sectionPlanesOverview",
overviewVisible: false,
});
sectionPlanesPlugin.setOverviewVisible(false);
applySectionPlaneTheme();
setSectionModeActive(false);
} else {
console.warn("SectionPlanesPlugin is not available in the loaded SDK build");
if (sectionButton) {
sectionButton.classList.add("disabled");
sectionButton.disabled = true;
sectionButton.title = "Разрез недоступен";
}
}
if (DistanceMeasurementsPlugin && DistanceMeasurementsMouseControl) {
measurePlugin = new DistanceMeasurementsPlugin(viewer, {
defaultAxisVisible: false,
defaultLabelsOnWires: true,
});
measureControl = new DistanceMeasurementsMouseControl(measurePlugin, {});
measureControl.snapping = true;
measurePlugin.on?.("measurementCreated", applyMeasureTheme);
if (measureModal) {
measurementModal = createMeasurementModal({
panel: measureModal,
backdrop: measureModalBackdrop,
onClose: () => {
activeMeasurementId = null;
},
onDelete: (id) => {
if (!measurePlugin || !id) return;
measurePlugin.destroyMeasurement(id);
closeMeasurementModal();
},
onToggleVisibility: (id, nextVisible) => {
const measurement = measurePlugin?.measurements?.[id];
if (!measurement) return;
measurement.visible = nextVisible;
measurementModal?.setVisibility?.(measurement.visible);
},
});
measurePlugin.on?.("contextMenu", (evt) => {
const measurement = evt?.measurement || evt?.distanceMeasurement;
if (!measurement) return;
evt.event?.preventDefault?.();
activeMeasurementId = measurement.id;
measurementModal?.show?.({
measurementId: measurement.id,
visible: measurement.visible !== false,
});
});
}
applyMeasureTheme();
setMeasureActive(false);
} else {
console.warn("DistanceMeasurementsPlugin is not available in the loaded SDK build");
if (measureButton) {
measureButton.classList.add("disabled");
measureButton.title = "Линейка недоступна";
}
}
viewer.transparent = toggleTransparent.checked;
if (viewer.scene?.canvas) {
viewer.scene.canvas.transparent = toggleTransparent.checked;
}
applyLightingMode();
updateProjectionButton();
if (mainCanvas && !mainCanvas.hasAttribute("tabindex")) {
mainCanvas.setAttribute("tabindex", "0");
}
if (mainCanvas) {
mainCanvas.addEventListener("pointerdown", () => {
focusCanvasAndControls();
}, true);
mainCanvas.addEventListener("pointerenter", scheduleCameraRearm);
}
rebuildNavCube(sdk);
resetTreeView(sdk);
applyToolbarVars(settingsMenu?.getState?.());
updateToolbarDockMode();
applyHighlightTheme();
updatePanelsVisibility();
ensureCameraControlActive();
gizmo = new TransformGizmo({
viewer,
sdk,
treeView,
transformStore,
sceneModels,
onTargetChange: (data) => {
if (!data) {
updateTransformInputs(null);
return;
}
updateTransformInputs(data.transform);
},
onTransformChange: (payload) => {
const { modelId, objectId, transform } = payload;
persistTransform(modelId, objectId || "__model__", transform);
if (selectionState) {
applyGroupTransform({
position: transform.position,
rotation: transform.rotation,
scale: transform.scale,
});
} else {
// одиночный объект через гизмо
const model = sceneModels[modelId];
if (model) {
model.position = transform.position ? [...transform.position] : model.position;
model.rotation = transform.rotation ? [...transform.rotation] : model.rotation;
model.scale = transform.scale ? [...transform.scale] : model.scale;
updateTransformInputs(transform);
viewer.scene.setAABBDirty?.();
viewer.scene.glRedraw?.();
}
}
},
});
// Пик по сцене: ЛКМ по объекту -> выделение (ПКМ контекст меню отключено)
const canvasEl = viewer.scene?.canvas?.canvas;
if (canvasEl) {
let downPos = null;
const clearCommentLongPress = () => {
if (commentLongPressTimer) {
window.clearTimeout(commentLongPressTimer);
commentLongPressTimer = null;
}
commentLongPressStart = null;
};
canvasEl.addEventListener("contextmenu", (event) => {
const opened = openCommentContextMenuFromEvent(event);
if (!opened) event.preventDefault();
});
canvasEl.addEventListener("pointerdown", restorePointerNavigation, { capture: true });
canvasEl.addEventListener("wheel", guardEmptyWheelNavigation, { capture: true, passive: true });
canvasEl.addEventListener("pointerdown", (event) => {
if (measureActive) {
downPos = null;
clearCommentLongPress();
return;
}
downPos = { x: event.clientX, y: event.clientY };
if (!isGuestMode() && activeModels.length && (event.pointerType === "touch" || event.pointerType === "pen")) {
clearCommentLongPress();
commentLongPressStart = {
x: event.clientX,
y: event.clientY,
};
commentLongPressTimer = window.setTimeout(() => {
if (!commentLongPressStart) return;
const syntheticEvent = {
clientX: commentLongPressStart.x,
clientY: commentLongPressStart.y,
preventDefault: () => {},
stopPropagation: () => {},
};
if (openCommentContextMenuFromEvent(syntheticEvent)) {
suppressNextCanvasClick = true;
}
clearCommentLongPress();
}, 620);
}
});
canvasEl.addEventListener("pointermove", (event) => {
if (!commentLongPressStart) return;
const dx = Math.abs(event.clientX - commentLongPressStart.x);
const dy = Math.abs(event.clientY - commentLongPressStart.y);
if (dx > 10 || dy > 10) clearCommentLongPress();
});
canvasEl.addEventListener("pointerup", clearCommentLongPress);
canvasEl.addEventListener("pointercancel", clearCommentLongPress);
canvasEl.addEventListener("pointerleave", clearCommentLongPress);
canvasEl.addEventListener("wheel", () => {
hideCommentContextMenu();
clearCommentLongPress();
});
canvasEl.addEventListener("click", (event) => {
if (measureActive) {
return;
}
if (suppressNextCanvasClick) {
suppressNextCanvasClick = false;
downPos = null;
return;
}
if (downPos) {
const dx = Math.abs(event.clientX - downPos.x);
const dy = Math.abs(event.clientY - downPos.y);
// если это был драг камеры, не считаем кликом по объекту
if (dx > 3 || dy > 3) {
downPos = null;
return;
}
}
const rect = canvasEl.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const hit = viewer.scene.pick({
canvasPos: [x, y],
pickSurface: true,
});
if (hit && hit.entity) {
const objectId = hit.entity.id || hit.entity.objectId;
const modelId = hit.entity.model?.id || hit.entity.modelId;
if (objectId && modelId) {
selectObject({ modelId, objectId });
}
} else {
// клик в пустоту — снимаем выделение
viewer.scene.setObjectsHighlighted(viewer.scene.highlightedObjectIds || [], false);
gizmo?.clearTarget();
updateTransformInputs(null);
treeView?.unShowNode?.();
}
});
}
// Делегирование клика по инспектору (дереву)
if (treeContainer) {
const handleTreeHit = (li, { fly = false } = {}) => {
let didFly = false;
if (!li) return didFly;
// если это основной TreeViewPlugin узел
if (treeView && treeView._nodeNodes && treeView._nodeNodes[li.id]) {
const node = treeView._nodeNodes[li.id];
highlightTreeNode(node, { fromTree: true });
if (fly) {
const ids = collectObjectIds(node);
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
flyToObjectIds(ids, modelId);
didFly = true;
}
return didFly;
}
// fallback дерево (ручное)
const span = li.querySelector(":scope > span");
const text = (span?.textContent || "").trim();
if (!text) return didFly;
// корень модели
if (li.parentElement === treeContainer.querySelector("ul")) {
if (sceneModels[text]) {
const ids = Object.keys(sceneModels[text].objects || {});
setSelection(ids, text);
if (fly) {
flyToObjectIds(ids, text);
didFly = true;
}
}
return didFly;
}
// дочерний узел fallback-дерева
let modelId = null;
let parentLi = li;
while (parentLi) {
const titleEl = parentLi.querySelector(":scope > span");
const t = titleEl ? titleEl.textContent.trim() : "";
if (t && sceneModels[t]) {
modelId = t;
break;
}
parentLi = parentLi.parentElement?.closest("li");
}
if (modelId) {
setSelection([text], modelId);
if (fly) {
flyToObjectIds([text], modelId);
didFly = true;
}
}
return didFly;
};
treeContainer.addEventListener("click", (e) => {
const target = e.target;
if (!target) return;
if (target === treeContainer) {
console.info("[DC Tree] click empty area");
viewer.scene.setObjectsHighlighted(viewer.scene.highlightedObjectIds || [], false);
gizmo?.clearTarget();
treeView?.unShowNode?.();
updateTransformInputs(null);
updateGlobalInputs(null);
return;
}
if (target.tagName === "INPUT") return; // игнор чекбоксов
const li = target.closest("li");
handleTreeHit(li, { fly: false });
});
treeContainer.addEventListener("dblclick", (e) => {
const target = e.target;
if (!target) return;
if (target.tagName === "INPUT") return;
const li = target.closest("li");
console.info("[DC Tree] dblclick handler", { liId: li?.id });
const didFly = handleTreeHit(li, { fly: true });
if (!didFly) {
const fallbackIds = selectionState?.ids && selectionState.ids.length
? selectionState.ids
: (activeModels[0]?.model ? Object.keys(activeModels[0].model.objects || {}) : []);
const fallbackModelId = selectionState?.modelId || activeModels[0]?.model?.id || null;
if (fallbackIds?.length) {
flyToObjectIds(fallbackIds, fallbackModelId);
}
}
});
treeContainer.addEventListener("contextmenu", (e) => {
const target = e.target;
if (!target) return;
const li = target.closest("li");
if (!li) return;
let node = null;
if (treeView && treeView._nodeNodes && treeView._nodeNodes[li.id]) {
node = treeView._nodeNodes[li.id];
}
let modelId = null;
if (node) {
modelId = node.modelId || getModelIdFromNode(node) || node.id || null;
if (!modelId && node.objectId) {
modelId = findModelIdForObjectId(node.objectId);
}
}
// fallback дерево или не распознали modelId
if (!modelId) {
let span = li.querySelector(":scope > span");
let text = (span?.textContent || "").trim();
let cur = li;
// поднимаемся до корня списка
while ((!text || !sceneModels[text]) && cur?.parentElement) {
cur = cur.parentElement.closest("li");
span = cur?.querySelector(":scope > span");
text = (span?.textContent || "").trim();
}
if (text && sceneModels[text]) {
modelId = text;
}
}
if (!modelId) return;
e.preventDefault();
e.stopPropagation();
const label = getModelLabel(modelId);
objectContextModal?.show?.({ modelId, label });
}, true); // capture: перехватываем до внутренних обработчиков дерева
}
const loadModel = (options) => {
const debugOptions = options || {};
try {
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 : ""));
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;
}
if (replace) {
destroyAllModels({ preserveMeta: preserveMeta || isLoadingSavedProject });
}
if (modelSettings) {
applyModelViewerSettings(modelSettings, { applyCamera: false });
}
setStatus(`Загрузка: ${name || inferredType}...`);
setLoading(true);
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,
requestedId: forcedId || null,
url,
});
const xktDataSource = inferredType === "xkt"
? (isBlob || isMemory ? createMemoryDataSource() : new sdk.XKTDefaultDataSource({ cacheBuster: !isBlob }))
: null;
const gltfDataSource = inferredType === "gltf"
? (isBlob || isMemory ? createMemoryDataSource() : new sdk.GLTFDefaultDataSource({ cacheBuster: false }))
: null;
const lasDataSource = inferredType === "las"
? (isBlob || isMemory ? createMemoryDataSource() : null)
: null;
const stlDataSource = inferredType === "stl"
? (isBlob || isMemory ? createMemoryDataSource() : null)
: null;
const currentLoader = loaderFor(sdk, inferredType, {
cacheBuster: isBlob ? false : true,
dataSource: xktDataSource || gltfDataSource || lasDataSource || stlDataSource || undefined,
});
const requestedId = forcedId || (replace ? "model" : `model-${++modelCounter}`);
let srcToLoad = url;
if (inferredType === "obj" && typeof url === "string" && url.startsWith("memory:") && memoryStore.has(url)) {
const blobUrl = URL.createObjectURL(new Blob([memoryStore.get(url)], { type: "text/plain" }));
blobUrls.push(blobUrl);
srcToLoad = blobUrl;
}
const loadOpts = {
id: requestedId,
src: srcToLoad,
edges: edges ?? (inferredType === "gltf" || inferredType === "las" ? false : toggleEdges.checked),
};
if (inferredType === "gltf") loadOpts.autoMetaModel = true;
if (inferredType === "bim" || inferredType === "las") loadOpts.rotation = [-90, 0, 0];
if (inferredType === "stl") loadOpts.smoothNormals = true;
const currentModel = currentLoader.load(loadOpts);
const id = currentModel?.id || requestedId;
if (id !== requestedId && transformStore[requestedId] && !transformStore[id]) {
transformStore[id] = safeClone(transformStore[requestedId]);
}
sceneModels[id] = currentModel;
modelTypes[id] = inferredType;
ensureLoadedModelTransformAlias(id, modelSettings);
currentModel.on("loaded", () => {
reportViewerEvent("model-loaded", {
id,
requestedId,
inferredType,
name,
objectCount: Object.keys(viewer.scene.objects || {}).length,
src: srcToLoad,
});
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({}) });
}
if (modelSettings) {
setTimeout(() => applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: false }), 0);
}
setStatus(`Загружено: ${name || inferredType}`);
dropZone.classList.add("hidden");
if (startupReveal) {
markStartupContentReady();
}
setLoading(false);
if (inferredType === "las") {
snapshotPointCloudColors(id);
}
applyDisplayModeToModel(id);
updateNavCubeVisibility();
updatePanelsVisibility();
rearmCameraControl();
// добавляем в дерево только если есть метамодель
setTimeout(() => {
const metaPresent = hasMetaModel(id);
if (metaPresent && !treeModelIds.has(id) && treeView?.addModel) {
try {
treeView.addModel(id, { rootName: name || id });
treeModelIds.add(id);
} catch (err) {
console.warn("tree addModel", err);
}
}
const hasNodes = treeContainer?.querySelector("li");
if (!metaPresent || !hasNodes) {
renderFallbackTree(id);
}
}, 250);
});
currentModel.on("error", (err) => {
reportViewerEvent("model-error", {
id,
requestedId,
inferredType,
name,
error: err?.message || String(err),
src: srcToLoad,
}, "error");
showError(`Ошибка загрузки: ${err}`);
if (startupReveal) {
markStartupContentReady();
}
setLoading(false);
updateNavCubeVisibility();
updatePanelsVisibility();
});
activeModels.push({
model: currentModel,
loader: currentLoader,
type: inferredType,
src: typeof url === "string" ? url : srcToLoad,
name,
meta
});
updateNavCubeVisibility();
updatePanelsVisibility();
refreshModelVersionHistory({ silent: true }).catch(() => {});
refreshModelLods({ silent: true }).catch(() => {});
// если есть сохранённые трансформы для модели — применим
ensureLoadedModelTransformAlias(id, modelSettings);
applyStoredTransformsToModel(id);
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 || "Ошибка загрузки");
if (debugOptions.startupReveal) {
markStartupContentReady();
}
setLoading(false);
return null;
}
};
const loadModelAsync = (options) => new Promise((resolve, reject) => {
const model = loadModel(options);
if (!model || !model.on) {
reject(new Error("Не удалось инициировать загрузку модели"));
return;
}
let settled = false;
let timer = null;
const settle = (handler, value) => {
if (settled) return;
settled = true;
if (timer) window.clearTimeout(timer);
handler(value);
};
model.on("loaded", () => settle(resolve, model));
model.on("error", (err) => settle(reject, new Error(err?.message || err)));
if (!settled) {
timer = window.setTimeout(() => {
setLoading(false);
updateNavCubeVisibility();
updatePanelsVisibility();
settle(reject, new Error("Загрузка модели заняла слишком много времени"));
}, MODEL_LOAD_TIMEOUT_MS);
}
});
loadModelAsyncRef = loadModelAsync;
const loadStartupModelFromQuery = async () => {
const params = new URLSearchParams(window.location.search);
const sharedViewer = startupSharePayload?.viewer || null;
const src = sharedViewer?.src || params.get("url") || params.get("src");
reportViewerEvent("startup-query", {
search: window.location.search,
mode: viewerMode,
hasShare: !!sharedViewer,
hasUrl: params.has("url"),
hasSrc: params.has("src"),
src,
typeParam: sharedViewer?.type || params.get("type"),
nameParam: sharedViewer?.name || params.get("name"),
});
if (!src) {
reportViewerEvent("startup-no-src", { search: window.location.search });
clearShareStartupPending();
setLoading(false);
markStartupContentReady();
return;
}
const name = sharedViewer?.name || params.get("name") || src.split("/").pop() || "model";
const type = normalizeType(sharedViewer?.type || params.get("type"), src) || normalizeType(guessTypeFromName(name));
if (!type) {
reportViewerEvent("startup-type-error", { src, name, typeParam: params.get("type") }, "error");
showError("Не удалось определить формат модели из URL");
clearShareStartupPending();
setLoading(false);
markStartupContentReady();
return;
}
dropZone?.classList.add("hidden");
setStatus(`Подготовка: ${name}...`);
setLoading(true);
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";
setDisplayModeControlState();
}
setCustomDisplaySettings({
color: params.has("customDisplayColor") ? params.get("customDisplayColor") : undefined,
saturation: params.has("customDisplaySaturation") ? params.get("customDisplaySaturation") : undefined,
});
if ((params.has("customDisplayColor") || params.has("customDisplaySaturation")) && normalizeDisplayMode(activeDisplayMode) === "custom") {
applyDisplayMode();
}
if (params.has("lightingMode") || params.has("light")) {
activeLightingMode = params.get("lightingMode") || params.get("light") || "technical";
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();
const startupMeta = {
label: name,
source: sharedViewer ? "share" : "query",
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src),
artifactSrc: normalizeModelSettingsSrc(src),
assetId: sharedViewer?.assetId || null,
projectId: sharedViewer?.projectId || null,
versionId: sharedViewer?.versionId || null,
selectedLod: sharedViewer?.selectedLod || null,
versions: Array.isArray(sharedViewer?.versions) ? sharedViewer.versions : [],
shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(),
viewerSettings: sharedViewerSettings,
};
const applyFetchedStartupSettings = (settingsResponse) => {
const modelSettings = sharedViewerSettings || settingsResponse?.viewerSettings || null;
settingsSrc = settingsResponse?.sourceSrc || settingsSrc;
reportViewerEvent("startup-settings-loaded", {
settingsSrc,
hasViewerSettings: !!modelSettings,
});
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: settingsResponse?.artifactSrc || startupMeta.artifactSrc,
artifactType: normalizeType(null, settingsResponse?.artifactSrc || startupMeta.artifactSrc) || startupMeta.artifactType || null,
xktConversion: settingsResponse?.xktConversion || startupMeta.xktConversion || null,
lodGeneration: settingsResponse?.lodGeneration || null,
lods: Array.isArray(settingsResponse?.lods) ? settingsResponse.lods : [],
selectedLod: settingsResponse?.selectedLod || startupMeta.selectedLod || null,
viewerSettings: modelSettings,
};
updateModelLodControl();
}
if (!modelSettings) return;
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 = {
...(startupMeta.viewerSettings || {}),
viewerState: {
...(startupMeta.viewerSettings?.viewerState || {}),
edges: params.get("edges") !== "false"
}
};
}
reportViewerEvent("startup-load-model", {
src,
name,
type,
settingsSrc,
hasViewerSettings: !!sharedViewerSettings,
});
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) => {
console.warn("startup model load failed", err);
reportViewerEvent("startup-load-failed", {
message: err?.message || String(err),
stack: err?.stack,
}, "error");
showError(err.message || "Не удалось загрузить модель из URL");
markStartupContentReady();
});
const loadProjectSnapshot = async (project) => {
if (!project) {
throw new Error("Нет данных проекта");
}
isLoadingSavedProject = true;
suppressEdgeReload = true;
try {
destroyAllModels({ preserveMeta: false });
currentProjectMeta = { id: project.id, name: project.name, type: project.type };
if (projectNameInput) {
projectNameInput.disabled = false;
projectNameInput.value = project.name || "";
}
Object.keys(transformStore).forEach((k) => delete transformStore[k]);
const savedTransforms = project.viewerState?.transforms || project.viewerState?.transformStore;
if (savedTransforms) {
Object.assign(transformStore, safeClone(savedTransforms));
}
const models = Array.isArray(project.models) ? project.models : [];
if (!models.length) {
applyViewerState(project.viewerState);
applyDesignState(project.design);
updatePanelsVisibility();
return;
}
for (let i = 0; i < models.length; i++) {
const modelCfg = models[i] || {};
const modelId = modelCfg.id || (i === 0 ? "model" : `model-${i + 1}`);
const modelType = normalizeType(modelCfg.type, modelCfg.src) || normalizeType(guessTypeFromName(modelCfg.src));
if (!modelType) {
throw new Error(`Не удалось определить тип модели ${modelId}`);
}
await loadModelAsync({
type: modelType,
url: modelCfg.src,
name: modelCfg.meta?.label || modelCfg.name || modelId,
replace: i === 0,
id: modelId,
meta: modelCfg.meta
});
}
applyViewerState(project.viewerState);
applyDesignState(project.design);
// Повторно накатываем трансформы чуть позже, если загрузчики внутри что-то сбросили
setTimeout(() => {
Object.keys(sceneModels).forEach((mid) => applyStoredTransformsToModel(mid));
}, 50);
setStatus(`Проект "${project.name}" загружен`);
templatesMenu?.hide();
} finally {
isLoadingSavedProject = false;
suppressEdgeReload = false;
updatePanelsVisibility();
rearmCameraControl();
}
};
const loadProjectFromServer = async (projectId) => {
try {
lastAttemptedProjectId = projectId;
const meta = findProjectMetaById(projectId) || { id: projectId, name: "", type: "project" };
currentProjectMeta = meta;
if (deleteProjectButton) deleteProjectButton.disabled = false;
if (projectNameInput) {
projectNameInput.disabled = false;
projectNameInput.value = meta.name || "";
}
updatePanelsVisibility();
setStatus("Загрузка проекта...");
setLoading(true);
const project = await fetchProjectById(projectId);
currentProjectMeta = { id: project.id, name: project.name, type: project.type };
if (deleteProjectButton) deleteProjectButton.disabled = false;
await loadProjectSnapshot(project);
} catch (err) {
console.error(err);
showError(err.message || "Не удалось загрузить проект");
} finally {
setLoading(false);
updatePanelsVisibility();
}
};
const closeSaveModal = () => {
saveProjectBackdrop?.classList.add("hidden");
saveProjectModal?.classList.add("hidden");
if (saveProjectError) saveProjectError.textContent = "";
if (saveProjectConfirm) {
saveProjectConfirm.disabled = false;
saveProjectConfirm.textContent = "Сохранить";
}
if (saveProjectDuplicate) {
saveProjectDuplicate.disabled = false;
saveProjectDuplicate.textContent = "Сохранить как новый";
}
if (saveProjectHint) {
saveProjectHint.textContent = "Введите имя, чтобы сохранить проект в хранилище";
}
};
const openSaveModal = () => {
if (isGuestMode()) return;
if (!activeModels.length && !currentProjectMeta) return;
const defaultName = currentProjectMeta?.name || "Новый проект";
if (saveProjectNameInput) {
saveProjectNameInput.value = defaultName;
saveProjectNameInput.focus();
saveProjectNameInput.select();
}
saveProjectError && (saveProjectError.textContent = "");
saveProjectBackdrop?.classList.remove("hidden");
saveProjectModal?.classList.remove("hidden");
if (saveProjectConfirm) {
saveProjectConfirm.disabled = !(saveProjectNameInput?.value.trim());
}
if (saveProjectDuplicate) {
saveProjectDuplicate.disabled = !(saveProjectNameInput?.value.trim());
}
};
const closeDeleteModal = () => {
deleteProjectBackdrop?.classList.add("hidden");
deleteProjectModal?.classList.add("hidden");
if (deleteProjectError) deleteProjectError.textContent = "";
if (deleteProjectConfirm) {
deleteProjectConfirm.disabled = false;
deleteProjectConfirm.textContent = "Удалить";
}
};
const openDeleteModal = () => {
if (isGuestMode()) return;
if (!currentProjectMeta && lastAttemptedProjectId) {
const meta = findProjectMetaById(lastAttemptedProjectId) || { id: lastAttemptedProjectId, name: "", type: "project" };
currentProjectMeta = meta;
}
if (!currentProjectMeta) return;
deleteProjectBackdrop?.classList.remove("hidden");
deleteProjectModal?.classList.remove("hidden");
if (deleteProjectError) deleteProjectError.textContent = "";
};
const submitDeleteProject = async () => {
if (isGuestMode()) return;
const targetId = currentProjectMeta?.id || lastAttemptedProjectId;
if (!targetId) return;
if (deleteProjectError) deleteProjectError.textContent = "";
if (deleteProjectConfirm) {
deleteProjectConfirm.disabled = true;
deleteProjectConfirm.textContent = "Удаление...";
}
try {
await fetchJSON(`${API_BASE}/${targetId}`, { method: "DELETE" });
await loadProjectsList().catch(() => {});
destroyAllModels();
currentProjectMeta = null;
lastAttemptedProjectId = null;
setStatus("Проект удалён");
if (projectNameInput) {
projectNameInput.value = "";
projectNameInput.disabled = true;
}
closeDeleteModal();
updatePanelsVisibility();
templatesMenu?.hide();
if (deleteProjectButton) deleteProjectButton.disabled = true;
} catch (err) {
console.error(err);
if (deleteProjectError) deleteProjectError.textContent = err.message || "Не удалось удалить проект";
} finally {
if (deleteProjectConfirm) {
deleteProjectConfirm.disabled = false;
deleteProjectConfirm.textContent = "Удалить";
}
}
};
const submitSaveProject = async (mode = "update") => {
if (isGuestMode()) return;
if (!saveProjectNameInput) return;
const trimmedName = saveProjectNameInput.value.trim();
if (!trimmedName) {
if (saveProjectError) saveProjectError.textContent = "Имя проекта обязательно";
if (saveProjectConfirm) saveProjectConfirm.disabled = true;
return;
}
if (saveProjectError) saveProjectError.textContent = "";
const targetId = currentProjectMeta?.id || lastAttemptedProjectId || null;
const targetType = currentProjectMeta?.type || "project";
const effectiveMode = (mode === "new") ? "new" : (targetId ? "update" : "new");
const isUpdate = effectiveMode === "update" && !!targetId;
if (saveProjectConfirm) {
saveProjectConfirm.disabled = true;
saveProjectConfirm.textContent = isUpdate ? "Сохранение..." : "Сохранение...";
}
if (saveProjectDuplicate) {
saveProjectDuplicate.disabled = true;
saveProjectDuplicate.textContent = mode === "new" ? "Сохранение..." : "Сохранить как новый";
}
if (saveProjectHint) {
saveProjectHint.textContent = "Сохраняем проект...";
}
try {
const payload = await buildProjectSnapshot(trimmedName);
payload.type = targetType || payload.type || "project";
if (isUpdate && targetId) {
payload.id = targetId;
} else {
delete payload.id;
}
const saved = await fetchJSON(isUpdate ? `${API_BASE}/${targetId}` : API_BASE, {
method: isUpdate ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
currentProjectMeta = { id: saved.id, name: saved.name, type: saved.type };
lastAttemptedProjectId = currentProjectMeta.id;
await loadProjectsList().catch(() => {});
closeSaveModal();
setStatus(`Проект "${saved.name}" ${isUpdate ? "обновлён" : "сохранён"}`);
if (deleteProjectButton) deleteProjectButton.disabled = !currentProjectMeta;
if (projectNameInput) {
projectNameInput.disabled = false;
projectNameInput.value = saved.name || "";
}
} catch (err) {
console.error(err);
if (saveProjectError) saveProjectError.textContent = err.message || "Не удалось сохранить проект";
} finally {
if (saveProjectConfirm) {
saveProjectConfirm.disabled = false;
saveProjectConfirm.textContent = "Сохранить";
}
if (saveProjectDuplicate) {
saveProjectDuplicate.disabled = false;
saveProjectDuplicate.textContent = "Сохранить как новый";
}
if (saveProjectHint) {
saveProjectHint.textContent = "Введите имя, чтобы сохранить проект в хранилище";
}
}
};
// Меню шаблонов / проектов (после объявления loadModel)
templatesMenu = createTemplatesMenu({
panel: templatesPanel,
fetchProjects: () => loadProjectsList(),
openProject: (projectId) => loadProjectFromServer(projectId),
onShow: () => {
bringFloatingWindowToFront("templates");
setToolbarButtonActive(templatesButton, true);
setToolbarButtonActive(settingsButton, false);
},
onHide: () => {
setToolbarButtonActive(templatesButton, false);
}
});
settingsMenu = createSettingsMenu({
panel: settingsPanel,
controls: {
toggleEdges,
toggleTransparent,
addMode,
},
onChange: ({ key }) => {
if (key === "cubeText" || key === "cubeEdges" || (key && key.startsWith("navCube"))) {
rebuildNavCube(sdk);
}
if (key === "highlight") {
applyHighlightTheme();
}
if (key && key.startsWith("measure")) {
applyMeasureTheme();
}
if (key && key.startsWith("section")) {
applySectionPlaneTheme();
}
if (key && key.startsWith("commentTarget")) {
renderCommentTargets();
}
if (key && key.startsWith("toolbar")) {
applyToolbarVars(settingsMenu?.getState?.());
updateToolbarDockMode();
}
focusCanvasAndControls();
},
onShow: () => {
bringFloatingWindowToFront("settings");
setToolbarButtonActive(settingsButton, true);
setToolbarButtonActive(templatesButton, false);
closeTemplatesPanel();
},
onHide: () => {
setToolbarButtonActive(settingsButton, false);
}
});
if (pendingDesignState) {
const design = pendingDesignState;
pendingDesignState = null;
applyDesignState(design);
}
// ensure persisted settings are applied to cube/highlight on first load
applyToolbarVars(settingsMenu?.getState?.());
updateToolbarDockMode();
rebuildNavCube(sdk);
applyHighlightTheme();
applyMeasureTheme();
applySectionPlaneTheme();
if (modelVersionControl && modelVersionSelectHost) {
modelVersionSelect = createGlassSelect({
host: modelVersionSelectHost,
value: "loading",
options: [{ value: "loading", label: "Версии не найдены" }],
ariaLabel: "Версия модели",
onChange: (value) => {
const versions = modelVersionHistory?.versions || getPrimaryModelEntry()?.meta?.versions || [];
const version = versions.find((item) => getVersionKey(item) === value);
if (version) openModelVersion(version);
},
});
updateModelVersionControl();
}
if (modelLodControl && modelLodSelectHost) {
modelLodSelect = createGlassSelect({
host: modelLodSelectHost,
value: "loading",
options: [{ value: "loading", label: "LOD не сгенерирован" }],
ariaLabel: "Уровень LOD",
onChange: (value) => {
const lod = getEntryLods(getPrimaryModelEntry()).find((item) => getLodKey(item) === value);
if (lod) openModelLod(lod);
},
});
updateModelLodControl();
}
if (displayModeControl && displayModeSelectHost) {
displayModeSelect = createGlassSelect({
host: displayModeSelectHost,
value: activeDisplayMode,
options: DISPLAY_MODE_OPTIONS,
ariaLabel: "Режим отображения",
onChange: (value) => {
activeDisplayMode = value;
applyDisplayMode();
focusCanvasAndControls();
},
});
setDisplayModeControlState();
}
if (customDisplayColorControlHost) {
customDisplayColorControl = createColorControl({
value: activeCustomDisplayColor,
ariaLabel: "Цвет кастомного режима",
valueLabel: "HEX цвет кастомного режима",
onChange: (value) => {
setCustomDisplaySettings({ color: value });
if (normalizeDisplayMode(activeDisplayMode) === "custom") {
applyDisplayMode();
}
},
});
customDisplayColorControlHost.replaceChildren(customDisplayColorControl);
}
if (customDisplaySaturationControl) {
customDisplaySaturationSlider = createSliderControl({
label: "Насыщенность",
value: activeCustomDisplaySaturation,
min: 0,
max: 200,
step: 1,
className: "inspector-slider",
showValue: true,
formatValue: (value) => `${Math.round(value)}%`,
onChange: (value) => {
setCustomDisplaySettings({ saturation: value });
if (normalizeDisplayMode(activeDisplayMode) === "custom") {
applyDisplayMode();
}
},
});
customDisplaySaturationControl.replaceChildren(customDisplaySaturationSlider);
}
setCustomDisplayControlsState();
if (lightingModeControl && lightingModeSelectHost) {
lightingModeSelect = createGlassSelect({
host: lightingModeSelectHost,
value: activeLightingMode,
options: LIGHTING_MODE_OPTIONS,
ariaLabel: "Свет",
onChange: (value) => {
activeLightingMode = value;
applyLightingMode({ announce: true });
focusCanvasAndControls();
},
});
setLightingModeControlState();
}
if (navigationAxisControl && navigationAxisSelectHost) {
navigationAxisSelect = createGlassSelect({
host: navigationAxisSelectHost,
value: activeNavigationAxis,
options: NAVIGATION_AXIS_OPTIONS,
ariaLabel: "Ось навигации",
onChange: (value) => {
activeNavigationAxis = value;
applyNavigationAxis({ resetAuto: true, announce: true });
focusCanvasAndControls();
},
});
setNavigationAxisControlState();
}
if (zoomSpeedControl) {
zoomSpeedSlider = createSliderControl({
label: "Скорость зума",
value: activeZoomSpeed,
min: 5,
max: 100,
step: 1,
className: "inspector-slider",
showValue: true,
formatValue: (value) => `${Math.round(value)}%`,
onChange: (value) => {
setZoomSpeed(value);
},
});
zoomSpeedControl.replaceChildren(zoomSpeedSlider);
}
inspectorCollapseButton?.addEventListener("click", () => setInspectorCollapsed(true));
inspectorRestoreButton?.addEventListener("click", () => {
setInspectorCollapsed(false);
bringFloatingWindowToFront("inspector");
});
modelVersionHistoryButton?.addEventListener("click", () => openVersionHistoryModal());
modelVersionUploadButton?.addEventListener("click", () => {
if (isGuestMode() || modelVersionUploading) return;
modelVersionFileInput?.click();
});
modelVersionFileInput?.addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) await uploadModelVersion(file);
});
modelLodGenerateButton?.addEventListener("click", () => {
generateModelLods();
});
registerFloatingWindowElement("inspector", sidePanelEl);
registerFloatingWindowElement("comments", commentsPanel);
registerFloatingWindowElement("settings", settingsPanel);
registerFloatingWindowElement("templates", templatesPanel);
registerFloatingWindowElement("commentModal", commentModal);
setupDraggableFixedWindow(commentsPanel, commentsPanelHeader);
setupDraggableFixedWindow(commentModal, commentModalHeader);
setupResizableFixedWindow(commentModal, { minWidth: 660, minHeight: 420 });
commentsCollapseButton?.addEventListener("click", () => setCommentsCollapsed(true));
commentsRestoreButton?.addEventListener("click", () => {
commentsModeActive = true;
commentsCollapsed = false;
bringFloatingWindowToFront("comments");
updateCommentsVisibility();
});
commentContextMenu?.addEventListener("click", (event) => {
const button = event.target?.closest?.("[data-comment-menu-action]");
if (!button) return;
event.preventDefault();
event.stopPropagation();
if (button.dataset.commentMenuAction === "create") {
hideCommentContextMenu();
openCommentModalForTarget();
}
});
document.addEventListener("click", (event) => {
if (!commentContextMenu || commentContextMenu.classList.contains("hidden")) return;
if (commentContextMenu.contains(event.target)) return;
hideCommentContextMenu();
});
commentModalClose?.addEventListener("click", closeCommentModal);
bindFooterButtonAction(commentCancelButton, closeCommentModal);
commentModalExpandButton?.addEventListener("click", () => setCommentModalExpanded(!commentModalExpanded));
bindFooterButtonAction(commentApplyButton, () => applyCommentModal({ closeOnSave: false }));
bindFooterButtonAction(commentSaveButton, () => applyCommentModal({ closeOnSave: true }));
commentApplyInlineButton?.addEventListener("click", () => applyCommentModal({ closeOnSave: false }));
commentModalBackdrop?.addEventListener("click", (event) => {
if (event.target === commentModalBackdrop) closeCommentModal();
});
commentAttachButton?.addEventListener("click", () => commentAttachmentInput?.click());
commentReplyAttachButton?.addEventListener("click", () => commentReplyAttachmentInput?.click());
commentAddSubitemButton?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
toggleCommentSubitemMenu();
});
commentSubitemMenu?.addEventListener("click", (event) => {
const button = event.target?.closest?.("[data-comment-subitem-kind]");
if (!button) return;
event.preventDefault();
event.stopPropagation();
addCommentSubitem(button.dataset.commentSubitemKind);
hideCommentSubitemMenu();
});
document.addEventListener("click", (event) => {
if (!commentSubitemMenu || commentSubitemMenu.classList.contains("hidden")) return;
if (commentSubitemMenu.contains(event.target) || commentAddSubitemButton?.contains(event.target)) return;
hideCommentSubitemMenu();
});
commentAttachmentInput?.addEventListener("change", async (event) => {
const files = await readAttachmentFiles(event.target.files);
commentDraftAttachments = [...commentDraftAttachments, ...files].slice(0, 8);
event.target.value = "";
renderModalAttachmentInputs();
});
commentReplyAttachmentInput?.addEventListener("change", async (event) => {
const files = await readAttachmentFiles(event.target.files);
commentReplyAttachments = [...commentReplyAttachments, ...files].slice(0, 8);
event.target.value = "";
renderModalAttachmentInputs();
});
if (commentDropzone) {
commentDropzone.addEventListener("click", (event) => {
if (event.target?.closest?.("button,input,.comment-attachment-thumb")) return;
commentAttachmentInput?.click();
});
["dragenter", "dragover"].forEach((type) => {
commentDropzone.addEventListener(type, (event) => {
event.preventDefault();
event.stopPropagation();
commentDropzone.classList.add("drag-over");
});
});
["dragleave", "drop"].forEach((type) => {
commentDropzone.addEventListener(type, (event) => {
event.preventDefault();
event.stopPropagation();
commentDropzone.classList.remove("drag-over");
});
});
commentDropzone.addEventListener("drop", async (event) => {
const files = await readAttachmentFiles(event.dataTransfer?.files);
commentDraftAttachments = [...commentDraftAttachments, ...files].slice(0, 8);
renderModalAttachmentInputs();
});
}
if (!isGuestMode()) {
loadProjectsList().catch(() => {
templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" });
});
}
const handleFile = async (file) => {
if (isGuestMode()) {
setStatus("Гостевой просмотр не принимает загрузку файлов");
return;
}
const detected = guessTypeFromName(file.name);
if (!detected) {
showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl");
return;
}
log(`Файл выбран: ${file.name} (${detected})`, false);
setStatus(`Загружаем: ${file.name}...`);
setLoading(true);
try {
const buf = await file.arrayBuffer();
const bytes = cloneToUint8(buf) || new Uint8Array(buf.byteLength || 0);
const uploaded = await uploadBuffer(bytes, file.name);
const uploadIdentity = {
projectId: uploaded.projectId || null,
assetId: uploaded.assetId || null,
};
loadModel({
type: detected,
url: uploaded.src,
name: file.name,
replace: !addMode.checked,
meta: {
label: file.name,
source: "upload",
settingsSrc: uploaded.src,
sourceSrc: uploaded.src,
artifactSrc: uploaded.src,
assetId: uploadIdentity.assetId,
projectId: uploadIdentity.projectId,
version: uploaded.version || null,
versionId: uploaded.versionId || null,
versions: [],
}
});
} catch (err) {
console.error(err);
showError(err.message || "Не удалось загрузить модель");
setLoading(false);
}
};
if (chooseFileBtn) {
chooseFileBtn.addEventListener("click", () => fileInput?.click());
}
if (fileInput) {
fileInput.addEventListener("change", (event) => {
const file = event.target.files?.[0];
if (file) handleFile(file);
fileInput.value = "";
});
}
if (viewerWrapper) {
["dragenter", "dragover"].forEach((evt) => {
viewerWrapper.addEventListener(evt, (e) => {
if (isGuestMode()) return;
e.preventDefault();
e.stopPropagation();
dropZone.classList.add("active");
});
});
["dragleave", "drop"].forEach((evt) => {
viewerWrapper.addEventListener(evt, (e) => {
if (isGuestMode()) return;
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove("active");
});
});
viewerWrapper.addEventListener("drop", (e) => {
if (isGuestMode()) return;
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer?.files?.[0];
if (file) {
handleFile(file);
}
});
}
fitViewBtn.addEventListener("click", () => {
viewer.cameraFlight.flyTo({ aabb: viewer.scene.getAABB({}) });
});
toggleEdges.addEventListener("change", async () => {
if (suppressEdgeReload) return;
if (!activeModels.length) return;
setStatus("Перезагрузка с новыми рёбрами...");
try {
const snapshot = await buildProjectSnapshot(currentProjectMeta?.name || "Текущий проект");
snapshot.id = currentProjectMeta?.id || "local_reload";
snapshot.type = currentProjectMeta?.type || "project";
await loadProjectSnapshot(snapshot);
} catch (err) {
console.error(err);
showError(err.message || "Не удалось перезагрузить сцену");
}
});
toggleTransparent.addEventListener("change", () => {
viewer.transparent = toggleTransparent.checked;
if (viewer.scene?.canvas) {
viewer.scene.canvas.transparent = toggleTransparent.checked;
}
});
homeButton.addEventListener("click", () => {
const sharePromoteUrl = getShareTokenFromPath() && isGuestMode() ? getShareTopLevelPromotionUrl() : "";
window.location.assign(sharePromoteUrl || getLauncherUrl());
});
if (saveProjectButton) {
saveProjectButton.addEventListener("click", openSaveModal);
}
if (saveModelSettingsButton) {
saveModelSettingsButton.addEventListener("click", saveCurrentModelSettings);
}
saveProjectCancel?.addEventListener("click", closeSaveModal);
saveProjectClose?.addEventListener("click", closeSaveModal);
saveProjectConfirm?.addEventListener("click", submitSaveProject);
saveProjectDuplicate?.addEventListener("click", () => submitSaveProject("new"));
saveProjectBackdrop?.addEventListener("click", (e) => {
if (e.target === saveProjectBackdrop) {
closeSaveModal();
}
});
saveProjectNameInput?.addEventListener("input", () => {
saveProjectError && (saveProjectError.textContent = "");
if (saveProjectConfirm) {
saveProjectConfirm.disabled = !saveProjectNameInput.value.trim();
}
if (saveProjectDuplicate) {
saveProjectDuplicate.disabled = !saveProjectNameInput.value.trim();
}
});
saveProjectNameInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
submitSaveProject();
}
});
projectNameInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
submitProjectRename();
}
});
projectNameInput?.addEventListener("blur", () => {
submitProjectRename();
});
if (deleteProjectButton) {
deleteProjectButton.addEventListener("click", openDeleteModal);
}
deleteProjectCancel?.addEventListener("click", closeDeleteModal);
deleteProjectClose?.addEventListener("click", closeDeleteModal);
deleteProjectConfirm?.addEventListener("click", submitDeleteProject);
deleteProjectBackdrop?.addEventListener("click", (e) => {
if (e.target === deleteProjectBackdrop) {
closeDeleteModal();
}
});
shareModalCancel?.addEventListener("click", closeShareModal);
shareModalClose?.addEventListener("click", closeShareModal);
shareCopyButton?.addEventListener("click", copyShareLink);
shareModalBackdrop?.addEventListener("click", (e) => {
if (e.target === shareModalBackdrop) {
closeShareModal();
}
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (commentSubitemMenu && !commentSubitemMenu.classList.contains("hidden")) {
hideCommentSubitemMenu();
e.preventDefault();
e.stopPropagation();
return;
}
if (commentModal && !commentModal.classList.contains("hidden")) {
closeCommentModal();
e.preventDefault();
e.stopPropagation();
return;
}
if (commentContextMenu && !commentContextMenu.classList.contains("hidden")) {
hideCommentContextMenu();
e.preventDefault();
e.stopPropagation();
return;
}
if (e.target && ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return;
if (shareModal && !shareModal.classList.contains("hidden")) {
closeShareModal();
e.preventDefault();
e.stopPropagation();
return;
}
if (deleteProjectModal && !deleteProjectModal.classList.contains("hidden")) {
closeDeleteModal();
e.preventDefault();
e.stopPropagation();
return;
}
if (saveProjectModal && !saveProjectModal.classList.contains("hidden")) {
closeSaveModal();
e.preventDefault();
e.stopPropagation();
return;
}
if (measureActive && measureControl?.currentMeasurement) {
measureControl.reset();
e.preventDefault();
e.stopPropagation();
}
});
// Горячие клавиши W/E/R для переключения режимов гизмы
document.addEventListener("keydown", (e) => {
if (!gizmo) return;
if (e.target && ["INPUT", "TEXTAREA"].includes(e.target.tagName)) return;
if (e.key === "w" || e.key === "W") gizmo.setMode("translate");
if (e.key === "e" || e.key === "E") gizmo.setMode("rotate");
if (e.key === "r" || e.key === "R") gizmo.setMode("scale");
});
modeButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const mode = btn.dataset.mode;
gizmo?.setMode(mode);
});
});
const bindInput = (key, transformKey) => {
const input = transformInputs[key];
if (!input) return;
input.addEventListener("focus", () => {
const fallbackModel =
selectionState?.modelId ||
gizmo?.target?.modelId ||
lastSelection?.modelId;
const fallbackObject =
selectionState?.ids?.[0] ||
gizmo?.target?.objectId ||
lastSelection?.ids?.[0] ||
"__model__";
if (fallbackModel) {
activeTransformTarget = { modelId: fallbackModel, objectId: fallbackObject || "__model__" };
}
});
const handler = (e) => {
const modelId =
selectionState?.modelId ||
gizmo?.target?.modelId ||
activeTransformTarget?.modelId ||
lastSelection?.modelId ||
null;
const objectId =
selectionState?.ids?.[0] ||
gizmo?.target?.objectId ||
activeTransformTarget?.objectId ||
lastSelection?.ids?.[0] ||
"__model__";
if (!modelId) return;
const model =
(sceneModels[modelId] || viewer?.scene?.models?.[modelId]) ||
null;
if (!model) return;
const entity =
objectId && objectId !== "__model__"
? resolveEntity(objectId, modelId) || model
: model;
const resolvedEntity = entity || model;
const modelTransforms = (modelId && transformStore[modelId]) || {};
const isInputEvent = e?.type === "input";
const beforeVal = input.value || "";
// Разрешаем свободное редактирование: пока в поле пусто или просто "-", не применяем и не перетираем значение
let rawVal = beforeVal.trim();
if (rawVal === "" || rawVal === "-") {
return;
}
const value = parseInputNumber(rawVal);
if (!Number.isFinite(value)) {
return;
}
// приоритет: значения из инпутов -> сохранённые -> текущие в сцене -> дефолты
const uiPos = [
parseInputNumber(transformInputs.posX?.value),
parseInputNumber(transformInputs.posY?.value),
parseInputNumber(transformInputs.posZ?.value),
];
const uiRot = [
parseInputNumber(transformInputs.rotX?.value),
parseInputNumber(transformInputs.rotY?.value),
parseInputNumber(transformInputs.rotZ?.value),
];
const uiScale = [
parseInputNumber(transformInputs.scaleX?.value),
parseInputNumber(transformInputs.scaleY?.value),
parseInputNumber(transformInputs.scaleZ?.value),
];
const stored = modelTransforms[objectId] || modelTransforms["__model__"] || {};
const basePos = stored.position
? [...stored.position]
: resolvedEntity?.position
? [...resolvedEntity.position]
: [0, 0, 0];
const baseRot = stored.rotation
? [...stored.rotation]
: resolvedEntity?.rotation
? [...resolvedEntity.rotation]
: [0, 0, 0];
const baseScale = stored.scale
? [...stored.scale]
: resolvedEntity?.scale
? [...resolvedEntity.scale]
: [1, 1, 1];
const next = {
position: [
Number.isFinite(uiPos[0]) ? uiPos[0] : basePos[0] || 0,
Number.isFinite(uiPos[1]) ? uiPos[1] : basePos[1] || 0,
Number.isFinite(uiPos[2]) ? uiPos[2] : basePos[2] || 0,
],
rotation: [
Number.isFinite(uiRot[0]) ? uiRot[0] : baseRot[0] || 0,
Number.isFinite(uiRot[1]) ? uiRot[1] : baseRot[1] || 0,
Number.isFinite(uiRot[2]) ? uiRot[2] : baseRot[2] || 0,
],
scale: [
Number.isFinite(uiScale[0]) ? uiScale[0] : baseScale[0] || 1,
Number.isFinite(uiScale[1]) ? uiScale[1] : baseScale[1] || 1,
Number.isFinite(uiScale[2]) ? uiScale[2] : baseScale[2] || 1,
],
};
if (transformKey === "position") {
const axis = key.slice(-1).toLowerCase();
const idx = axis === "x" ? 0 : axis === "y" ? 1 : 2;
next.position[idx] = value;
} else if (transformKey === "rotation") {
const axis = key.slice(-1).toLowerCase();
const idx = axis === "x" ? 0 : axis === "y" ? 1 : 2;
next.rotation[idx] = value;
} else if (transformKey === "scale") {
const axis = key.slice(-1).toLowerCase();
const idx = axis === "x" ? 0 : axis === "y" ? 1 : 2;
next.scale[idx] = value || 1;
}
if (selectionState) {
applyGroupTransform(next);
} else {
resolvedEntity.position = [...next.position];
resolvedEntity.rotation = [...next.rotation];
resolvedEntity.scale = [...next.scale];
const key =
resolvedEntity === model || !objectId ? "__model__" : objectId;
persistTransform(modelId, key, {
position: [...resolvedEntity.position],
rotation: [...resolvedEntity.rotation],
scale: [...resolvedEntity.scale],
});
activeTransformTarget = { modelId, objectId: key };
if (!isInputEvent) {
updateTransformInputs(next);
}
viewer.scene.setAABBDirty?.();
viewer.scene.glRedraw?.();
}
const refreshTarget = selectionState
? { modelId: modelId, objectId: objectId || "__model__" }
: (activeTransformTarget || gizmo?.target);
if (refreshTarget?.modelId) {
gizmo?.setTarget(refreshTarget);
}
};
input.addEventListener("change", handler);
input.addEventListener("blur", handler);
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
handler(ev);
input.blur();
}
});
};
bindInput("posX", "position");
bindInput("posY", "position");
bindInput("posZ", "position");
bindInput("rotX", "rotation");
bindInput("rotY", "rotation");
bindInput("rotZ", "rotation");
bindInput("scaleX", "scale");
bindInput("scaleY", "scale");
bindInput("scaleZ", "scale");
// Переустановим pivot камеры на центр текущего выделения при двойном клике в инспекторе, если не переданы ids
if (treeContainer) {
treeContainer.addEventListener("dblclick", () => {
if (selectionState?.pivotCenter && viewer?.cameraControl) {
viewer.cameraControl.pivotPos = selectionState.pivotCenter;
}
});
}
if (objectModal) {
objectContextModal = createObjectModal({
panel: objectModal,
backdrop: objectModalBackdrop,
onDelete: (target) => {
if (!target?.modelId) return;
deleteModelById(target.modelId);
objectContextModal?.hide?.();
},
onClose: () => {}
});
}
appShellSetupComplete = true;
revealAppShell();
void startupModelLoadPromise;
};
document.addEventListener("DOMContentLoaded", () => {
initViewer().catch((err) => {
reportViewerEvent("init-viewer-error", {
message: err?.message || String(err),
stack: err?.stack,
}, "error");
showError(err?.message || "Не удалось инициализировать viewer");
revealAppShell({ force: true });
});
});
// нижняя тулбар
if (bottomToolbar) {
setupToolbarDock();
bottomToolbar.addEventListener("click", (e) => {
const btn = e.target.closest(".tb-btn");
if (!btn) return;
if (btn.disabled) return;
const action = btn.dataset.action;
if (isGuestMode() && !isGuestToolbarAction(action)) return;
if (action === "settings") {
if (btn.classList.contains("active")) {
closeSettingsPanel();
} else {
openSettingsPanel();
}
} else if (action === "templates") {
if (btn.classList.contains("active")) {
closeTemplatesPanel();
} else {
openTemplatesPanel();
}
} else if (action === "measure") {
if (!measurePlugin || !measureControl) return;
setMeasureActive(!measureActive);
} else if (action === "section") {
toggleSectionPlanes();
} else if (action === "comments") {
toggleCommentsMode();
} else if (action === "projection") {
toggleProjection();
} else if (action === "share") {
openShareModal();
} else if (action === "add") {
fileInput.click();
}
});
}
sectionToolbar?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-section-action]");
if (!btn) return;
const action = btn.dataset.sectionAction;
if (action === "camera" || action === "x" || action === "y" || action === "z") {
createOrUpdateSectionPlane(action);
} else if (action === "flip") {
flipSectionPlanes();
} else if (action === "clear") {
clearSectionPlanes();
setStatus("Разрез выключен");
}
});
function updateTransformInputs(transform) {
const empty = {
position: ["", "", ""],
rotation: ["", "", ""],
scale: ["", "", ""],
};
const t = transform || empty;
const pos = t.position || ["", "", ""];
const rot = t.rotation || ["", "", ""];
const scale = t.scale || ["", "", ""];
transformInputs.posX.value = pos[0];
transformInputs.posY.value = pos[1];
transformInputs.posZ.value = pos[2];
transformInputs.rotX.value = rot[0];
transformInputs.rotY.value = rot[1];
transformInputs.rotZ.value = rot[2];
transformInputs.scaleX.value = scale[0];
transformInputs.scaleY.value = scale[1];
transformInputs.scaleZ.value = scale[2];
}