NODEDC_BIM_VIEWER/frontend/dcViewer.js

7264 lines
254 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const SDK_VERSION = "2.6.78"; // соответствует package.json
const SDK_URL = "./lib/dc-camera-context.es.js?v=3";
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 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;
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.008,
dollyProximityThreshold: 90,
};
const normalizeZoomSpeed = (value) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 20;
return Math.min(100, Math.max(5, Math.round(numeric)));
};
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);
// Keep wheel dolly speed independent from whether the pointer is over model geometry.
control.followPointer = false;
control.doublePickFlyTo = false;
control.smartPivot = false;
control.zoomOnMouseWheel = true;
control.keyboardDollyRate = CAMERA_CONTROL_SETTINGS.keyboardDollyRate;
control.mouseWheelDollyRate = activeZoomSpeed;
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 = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6.5 4.75h11A3.25 3.25 0 0 1 20.75 8v6.25a3.25 3.25 0 0 1-3.25 3.25h-5.26l-5.29 3.82V17.5H6.5a3.25 3.25 0 0 1-3.25-3.25V8A3.25 3.25 0 0 1 6.5 4.75Z"></path>
</svg>
`;
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 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 = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z"></path>
<path d="M14 2v5h5"></path>
</svg>
`;
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 = `
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z"></path>
<path d="M14 2v5h5"></path>
</svg>
`;
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*<!doctype html/i.test(text || "")) {
return `BIM API вернул HTML вместо JSON (HTTP ${res.status}). Проверь, что открыт backend-сервер BIM Viewer.`;
}
return (data && (data.message || data.error)) || text || `HTTP ${res.status}`;
};
const formatApiNonJsonMessage = (url, res, text) => {
const status = res?.status ? `HTTP ${res.status}` : "HTTP";
if (/^\s*<!doctype html/i.test(text || "")) {
return `BIM API на этом адресе вернул HTML вместо JSON (${status}). На localhost:8080 сейчас нет backend /api/comments.`;
}
return `BIM API вернул не-JSON ответ (${status}).`;
};
const fetchJSON = async (url, options = {}) => {
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 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) => {
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,
};
const settings = await fetchModelSettings(settingsSrc).catch(() => null);
if (settings?.viewerSettings) {
meta.viewerSettings = settings.viewerSettings;
}
await loadModelAsync({
type: viewerType,
url: viewerSrc,
name: version.originalFilename || previousEntry?.name || "model",
replace: true,
id: previousEntry?.model?.id || "model",
meta,
});
modelVersionHistory = {
...(modelVersionHistory || {}),
currentVersion: version.version || modelVersionHistory?.currentVersion || null,
currentVersionId: version.versionId || modelVersionHistory?.currentVersionId || null,
versions: modelVersionHistory?.versions || meta.versions || [],
};
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 = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M1.8 8s2.1-4.1 6.2-4.1S14.2 8 14.2 8 12.1 12.1 8 12.1 1.8 8 1.8 8Z"></path><circle cx="8" cy="8" r="1.7"></circle></svg>`;
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 = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2.8v7.1"></path><path d="m5.3 7.2 2.7 2.7 2.7-2.7"></path><path d="M3 12.8h10"></path></svg>`;
actions.appendChild(download);
}
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "inspector-icon-btn";
deleteButton.title = "Удалить версию";
deleteButton.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.2 4.5h9.6"></path><path d="M6.2 4.5V3.1h3.6v1.4"></path><path d="m4.4 4.5.55 8.4h6.1l.55-8.4"></path><path d="M6.65 6.5v4.3"></path><path d="M9.35 6.5v4.3"></path></svg>`;
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 = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75"></path></svg>`;
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 (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", (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 } = 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: 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,
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 id = 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,
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);
sceneModels[id] = currentModel;
modelTypes[id] = inferredType;
currentModel.on("loaded", () => {
reportViewerEvent("model-loaded", {
id,
inferredType,
name,
objectCount: Object.keys(viewer.scene.objects || {}).length,
src: srcToLoad,
});
setTimeout(() => applyStoredTransformsToModel(id), 0);
setTimeout(() => applyStoredTransformsToModel(id), 60);
const hasModelCamera = !!modelSettings?.viewerState?.camera;
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,
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;
}
model.on("loaded", () => resolve(model));
model.on("error", (err) => reject(new Error(err?.message || err)));
});
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];
}