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); 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 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 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 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 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 src = normalizeModelSettingsSrc(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", settingsSrc: settingsSrc || src, src, type: entry.type || normalizeType(null, src), versionId: entry.meta?.versionId || entry.meta?.conversion?.versionId || null, 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(); } 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 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 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, viewerSettings: null, }; try { const settings = await fetchModelSettings(settingsSrc).catch(() => null); if (settings?.viewerSettings) { meta.viewerSettings = settings.viewerSettings; } 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(); 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 buildProjectSnapshot = async (name) => { const models = []; 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], }; } }); for (const entry of activeModels) { const model = entry.model; const modelType = normalizeType(entry.type, entry.src) || normalizeType(guessTypeFromName(entry.src)) || entry.type || null; const src = typeof entry.src === "string" ? entry.src : ""; const meta = entry.meta || {}; 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 }, visible: model.visible !== false }; if (!modelType) { throw new Error("Не удалось определить тип модели для сохранения."); } if (isRemoteSrc(src)) { models.push({ ...baseModel, src }); continue; } const cacheKey = 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: activeModels.map((entry) => ({ id: entry.model?.id, visible: entry.model?.visible !== false })), 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 } : {}, 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, }); 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, viewerSettings: saved?.viewerSettings || payload.viewerSettings }; 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) => 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 preloadHidden = document.getElementById("preload-hidden"); if (preloadHidden) { preloadHidden.remove(); } document.body.style.opacity = "1"; const sdk = await import(SDK_URL); sdkRef = sdk; const { Viewer, NavCubePlugin, DistanceMeasurementsPlugin, DistanceMeasurementsMouseControl, SectionPlanesPlugin } = sdk; 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 } = 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; currentModel.on("loaded", () => { reportViewerEvent("model-loaded", { id, requestedId, inferredType, name, objectCount: Object.keys(viewer.scene.objects || {}).length, src: srcToLoad, }); setTimeout(() => applyStoredTransformsToModel(id), 0); setTimeout(() => 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"); 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}`); 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(() => {}); // если есть сохранённые трансформы для модели — применим 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 || "Ошибка загрузки"); 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 }, "error"); clearShareStartupPending(); setLoading(false); 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); 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 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, versions: Array.isArray(sharedViewer?.versions) ? sharedViewer.versions : [], shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(), viewerSettings: null, }; const applyFetchedStartupSettings = (settingsResponse) => { const modelSettings = settingsResponse?.viewerSettings || null; settingsSrc = settingsResponse?.sourceSrc || settingsSrc; reportViewerEvent("startup-settings-loaded", { settingsSrc, hasViewerSettings: !!modelSettings, }); if (!modelSettings) return; const normalizedSettingsSrc = normalizeModelSettingsSrc(settingsSrc) || startupMeta.settingsSrc; startupMeta.settingsSrc = normalizedSettingsSrc; startupMeta.viewerSettings = modelSettings; const entry = activeModels.find((item) => item?.meta === startupMeta) || getPrimaryModelEntry(); if (entry) { entry.meta = { ...(entry.meta || {}), settingsSrc: normalizedSettingsSrc, sourceSrc: normalizedSettingsSrc, artifactSrc: startupMeta.artifactSrc, viewerSettings: modelSettings, }; } applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: true }); applyQueryViewerOverrides(); }; fetchModelSettings(settingsSrc) .then(applyFetchedStartupSettings) .catch((err) => { console.warn("model settings unavailable", err); reportViewerEvent("startup-settings-unavailable", { settingsSrc, error: err?.message || String(err), }); }); if (params.has("edges")) { startupMeta.viewerSettings = { viewerState: { edges: params.get("edges") !== "false" } }; } reportViewerEvent("startup-load-model", { src, name, type, settingsSrc, hasViewerSettings: false, }); loadModel({ type, url: src, name, replace: params.get("replace") !== "false", edges: params.has("edges") ? params.get("edges") !== "false" : undefined, meta: startupMeta, }); }; const startupModelLoadPromise = loadStartupModelFromQuery().catch((err) => { console.warn("startup model load failed", err); reportViewerEvent("startup-load-failed", { message: err?.message || String(err), stack: err?.stack, }, "error"); showError(err.message || "Не удалось загрузить модель из URL"); }); const loadProjectSnapshot = async (project) => { if (!project) { throw new Error("Нет данных проекта"); } 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 (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); }); 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: () => {} }); } 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"); }); }); // нижняя тулбар 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]; }