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 { toolbarConfig, applyToolbarVars } from "./src/ui/toolbar.js";
import { createTemplatesMenu } from "./src/ui/templatesMenu.js?v=3";
import { createSettingsMenu } from "./src/ui/settingsMenu.js?v=11";
import { createMeasurementModal } from "./src/ui/measurementModal.js";
import { createGlassSelect } from "./src/ui/glassSelect.js";
import { createSliderControl } from "./src/ui/controls/slider.js";
import "./theme.js";
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 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 lightingModeControl = document.getElementById("lightingModeControl");
const lightingModeSelectHost = document.getElementById("lightingModeSelect");
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 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: "contrast", label: "Контраст" },
{ value: "edges", label: "Рёбра" },
{ value: "xray", label: "Рентген" },
];
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: "ambient", 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 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;
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 = "ambient";
let activeNavigationAxis = "auto";
let activeZoomSpeed = 20;
let inspectorCollapsed = false;
let displayModeSelect = null;
let lightingModeSelect = 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();
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 configureCameraPanMap = (control) => {
const input = viewer?.scene?.input;
if (!control || !input) return;
if (control.MOUSE_PAN === undefined || control.MOUSE_ROTATE === undefined || control.MOUSE_DOLLY === undefined) return;
control.panRightClick = true;
control.keyMap = {
...(control.keyMap || {}),
[control.MOUSE_PAN]: [
[input.MOUSE_LEFT_BUTTON, input.KEY_SHIFT],
input.MOUSE_MIDDLE_BUTTON,
input.MOUSE_RIGHT_BUTTON,
],
[control.MOUSE_ROTATE]: [input.MOUSE_LEFT_BUTTON],
[control.MOUSE_DOLLY]: [],
};
};
const configureCameraControl = () => {
const control = viewer?.cameraControl;
if (!control) return;
activeZoomSpeed = normalizeZoomSpeed(activeZoomSpeed);
configureCameraPanMap(control);
control.followPointer = true;
control.doublePickFlyTo = false;
control.smartPivot = true;
control.zoomOnMouseWheel = true;
control.keyboardDollyRate = CAMERA_CONTROL_SETTINGS.keyboardDollyRate;
control.mouseWheelDollyRate = 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 : "ambient"
);
const normalizeNavigationAxis = (axis) => (
NAVIGATION_AXIS_OPTIONS.some((option) => option.value === axis) ? axis : "auto"
);
const setDisplayModeControlState = () => {
activeDisplayMode = normalizeDisplayMode(activeDisplayMode);
displayModeSelect?.setValue(activeDisplayMode);
};
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 getShareTokenFromPath = () => {
const match = window.location.pathname.match(/^\/share\/([^/?#]+)/);
return match ? decodeURIComponent(match[1]) : null;
};
const COMMENT_TARGET_ICON = `
`;
const formatDateTime = (value) => {
if (!value) return "";
try {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
} catch (_) {
return String(value);
}
};
const setCommentModalError = (message = "") => {
if (!commentModalError) return;
const text = String(message || "").trim();
commentModalError.textContent = text;
commentModalError.hidden = !text;
};
const bringFloatingWindowToFront = (id) => {
if (!id) return;
floatingWindowOrder = [...floatingWindowOrder.filter((item) => item !== id), id];
const zBase = 30080;
floatingWindowOrder.forEach((item, index) => {
const element = floatingWindowElements.get(item);
if (element) element.style.zIndex = `${zBase + index * 10}`;
});
};
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 = `
`;
const meta = document.createElement("div");
meta.className = "comment-attachment-file__meta";
const name = document.createElement("strong");
name.textContent = attachment.name || "Файл";
const size = document.createElement("span");
size.textContent = formatFileSize(attachment.size) || attachment.type || "Файл";
meta.append(name, size);
file.appendChild(meta);
thumb.appendChild(file);
}
const remove = document.createElement("button");
remove.type = "button";
remove.setAttribute("aria-label", "Удалить файл");
remove.textContent = "×";
remove.addEventListener("click", () => onRemove?.(index));
thumb.appendChild(remove);
grid.appendChild(thumb);
});
};
const renderThreadAttachments = (container, attachments) => {
if (!attachments?.length) return;
const grid = document.createElement("div");
grid.className = "comment-attachment-grid";
attachments.forEach((attachment) => {
const thumb = document.createElement("div");
thumb.className = "comment-attachment-thumb";
const isImage = String(attachment.type || "").startsWith("image/") || /^data:image\//i.test(String(attachment.dataUrl || ""));
if (isImage) {
const img = document.createElement("img");
img.src = attachment.dataUrl;
img.alt = attachment.name || "attachment";
thumb.appendChild(img);
} else {
const file = document.createElement("div");
file.className = "comment-attachment-file";
file.innerHTML = `
`;
const meta = document.createElement("div");
meta.className = "comment-attachment-file__meta";
const name = document.createElement("strong");
name.textContent = attachment.name || "Файл";
const size = document.createElement("span");
size.textContent = formatFileSize(attachment.size) || attachment.type || "Файл";
meta.append(name, size);
file.appendChild(meta);
thumb.appendChild(file);
}
grid.appendChild(thumb);
});
container.appendChild(grid);
};
const renderCommentThread = (comment) => {
if (!commentThreadList) return;
commentThreadList.replaceChildren();
const messages = Array.isArray(comment?.messages) ? comment.messages : [];
if (!messages.length) {
const empty = document.createElement("div");
empty.className = "comment-thread-empty";
empty.textContent = "Комментариев пока нет";
commentThreadList.appendChild(empty);
return;
}
messages.forEach((message) => {
const item = document.createElement("div");
item.className = "comment-thread-message";
const head = document.createElement("div");
head.className = "comment-thread-message__head";
const author = document.createElement("strong");
author.textContent = message?.createdBy?.name || message?.createdBy?.email || "Пользователь";
const date = document.createElement("span");
date.textContent = formatDateTime(message?.updatedAt || message?.createdAt);
head.append(author, date);
const body = document.createElement("div");
body.className = "comment-thread-message__body";
body.textContent = message?.body || "";
item.append(head, body);
renderThreadAttachments(item, message?.attachments || []);
commentThreadList.appendChild(item);
});
};
const resetCommentModalAttachments = () => {
commentDraftAttachments = [];
commentReplyAttachments = [];
commentDraftSubitems = [];
renderModalAttachmentInputs();
renderCommentSubitems();
};
const renderModalAttachmentInputs = () => {
renderAttachmentGrid(commentAttachmentGrid, commentDraftAttachments, (index) => {
commentDraftAttachments.splice(index, 1);
renderModalAttachmentInputs();
});
renderAttachmentGrid(commentReplyAttachmentGrid, commentReplyAttachments, (index) => {
commentReplyAttachments.splice(index, 1);
renderModalAttachmentInputs();
});
};
const setCommentModalExpanded = (expanded) => {
commentModalExpanded = !!expanded;
commentModal?.classList.toggle("is-expanded", commentModalExpanded);
if (commentModalExpandButton) {
commentModalExpandButton.setAttribute("aria-label", commentModalExpanded ? "Свернуть" : "Развернуть");
commentModalExpandButton.title = commentModalExpanded ? "Свернуть" : "Развернуть";
}
};
const hideCommentSubitemMenu = () => {
commentSubitemMenu?.classList.add("hidden");
};
const showCommentSubitemMenu = () => {
if (!commentSubitemMenu || !commentAddSubitemButton) return;
if (commentSubitemMenu.parentElement !== document.body) {
document.body.appendChild(commentSubitemMenu);
}
commentSubitemMenu.classList.remove("hidden");
const buttonRect = commentAddSubitemButton.getBoundingClientRect();
const width = commentSubitemMenu.offsetWidth || 276;
const x = Math.max(8, Math.min(window.innerWidth - width - 8, buttonRect.left));
const y = buttonRect.bottom + 8;
commentSubitemMenu.style.left = `${x}px`;
commentSubitemMenu.style.top = `${y}px`;
};
const toggleCommentSubitemMenu = () => {
if (!commentSubitemMenu) return;
if (commentSubitemMenu.classList.contains("hidden")) {
showCommentSubitemMenu();
} else {
hideCommentSubitemMenu();
}
};
const addCommentSubitem = (kind) => {
const nextKind = kind === "text" ? "text" : "checker";
commentDraftSubitems.push({
id: `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`,
kind: nextKind,
title: "",
body: "",
done: false,
});
renderCommentSubitems();
window.requestAnimationFrame(() => {
const selector = nextKind === "text"
? ".ops-comment-subitem[data-kind='text'] textarea"
: ".ops-comment-subitem[data-kind='checker'] input[type='text']";
const inputs = commentSubitemsList?.querySelectorAll?.(selector);
inputs?.[inputs.length - 1]?.focus?.();
});
};
const renderCommentSubitems = () => {
if (!commentSubitemsList || !commentSubitemsSection) return;
commentSubitemsList.replaceChildren();
const subitems = Array.isArray(commentDraftSubitems) ? commentDraftSubitems : [];
commentSubitemsSection.classList.toggle("hidden", !subitems.length);
subitems.forEach((subitem, index) => {
const kind = subitem.kind === "text" ? "text" : "checker";
const row = document.createElement(kind === "text" ? "div" : "label");
row.className = "ops-comment-subitem";
row.dataset.kind = kind;
let editor = null;
if (kind === "text") {
editor = document.createElement("textarea");
editor.rows = 3;
editor.value = subitem.body || subitem.title || "";
editor.placeholder = "Текстовый блок";
editor.addEventListener("input", () => {
commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, body: editor.value, title: editor.value };
});
} else {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = !!subitem.done;
checkbox.addEventListener("change", () => {
commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, done: checkbox.checked };
});
editor = document.createElement("input");
editor.type = "text";
editor.value = subitem.title || subitem.body || "";
editor.placeholder = "Новый чекер";
editor.addEventListener("input", () => {
commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, title: editor.value };
});
row.appendChild(checkbox);
}
const remove = document.createElement("button");
remove.type = "button";
remove.setAttribute("aria-label", "Удалить подэлемент");
remove.textContent = "×";
remove.addEventListener("click", () => {
commentDraftSubitems.splice(index, 1);
renderCommentSubitems();
});
row.append(editor, remove);
commentSubitemsList.appendChild(row);
});
};
const openCommentModalForTarget = () => {
if (!commentContextTarget || !commentModal) return;
hideCommentSubitemMenu();
commentsModeActive = true;
commentsCollapsed = false;
commentModalMode = "create";
commentModalCommentId = null;
activeCommentId = "__draft_comment__";
commentTitleInput && (commentTitleInput.value = "");
commentBodyInput && (commentBodyInput.value = "");
commentReplyInput && (commentReplyInput.value = "");
if (commentModalSubtitle) {
commentModalSubtitle.textContent = "BIM-COMMENT";
}
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 = "");
if (commentModalSubtitle) {
commentModalSubtitle.textContent = comment.id || "BIM-COMMENT";
}
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 }))
: [];
if (commentModalSubtitle) {
commentModalSubtitle.textContent = comment.id || "BIM-COMMENT";
}
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 = () => {
document.body.dataset.viewerMode = viewerMode;
if (bottomToolbar) {
bottomToolbar.classList.toggle("hidden", isGuestMode());
}
if (commentsButton) {
commentsButton.classList.toggle("hidden", isGuestMode());
commentsButton.disabled = isGuestMode() || activeModels.length === 0;
}
if (isGuestMode()) {
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 = !isGuestMode() && runtimeSessionInfo.canShare && activeModels.length > 0;
shareButton.classList.toggle("hidden", !canShowShare);
shareButton.disabled = !canShowShare;
}
if (dropZone) {
dropZone.style.pointerEvents = isGuestMode() ? "none" : "";
}
if (sectionToolbar && isGuestMode()) {
sectionToolbar.classList.add("hidden");
}
};
const initializeRuntimeMode = async () => {
const shareToken = getShareTokenFromPath();
if (shareToken) {
viewerMode = "guest";
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);
}
applyRuntimeMode();
};
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;
}
return {
assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || 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,
};
};
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.86, 0.86, 0.82], opacity: 1, edges: false, xrayed: false };
case "white":
return { colorize: [1, 1, 1], opacity: 1, edges: false, xrayed: false };
case "contrast":
return { colorize: [0.78, 0.82, 1], 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":
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;
case "ambient":
default:
createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.92 });
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));
};
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 && !isGuestMode();
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 && !isGuestMode() && 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();
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 (!modelId || !transform) return;
if (!transformStore[modelId]) transformStore[modelId] = {};
const key = objectId || "__model__";
transformStore[modelId][key] = {
position: transform.position ? [...transform.position] : [0, 0, 0],
rotation: transform.rotation ? [...transform.rotation] : [0, 0, 0],
scale: transform.scale ? [...transform.scale] : [1, 1, 1],
};
// Для LAS фиксируем трансформ на уровне модели, т.к. objectId может меняться между загрузками
if (modelTypes[modelId] === "las" && key !== "__model__") {
transformStore[modelId]["__model__"] = {
position: transform.position ? [...transform.position] : [0, 0, 0],
rotation: transform.rotation ? [...transform.rotation] : [0, 0, 0],
scale: transform.scale ? [...transform.scale] : [1, 1, 1],
};
}
};
const applyStoredTransformsToModel = (modelId) => {
if (!viewer || !modelId || !sceneModels[modelId]) return false;
const modelTransforms = transformStore[modelId];
if (!modelTransforms) return false;
let applied = false;
const entries = Object.entries(modelTransforms).filter(([, tr]) => !!tr);
const hasModel = !!modelTransforms.__model__;
const nonModel = entries.filter(([key]) => key !== "__model__");
const isNeutral = (tr = {}) => {
const pos = tr.position || [0, 0, 0];
const rot = tr.rotation || [0, 0, 0];
const scale = tr.scale || [1, 1, 1];
const nearZero = (v) => Math.abs(v) < 1e-6;
return (
pos.every(nearZero) &&
rot.every(nearZero) &&
scale.every((v) => Math.abs(v - 1) < 1e-6)
);
};
// Heuristic: если у GLB/GLTF сохранён единственный трансформ (root-объект) и __model__ пустой,
// считаем его трансформом всей модели. Это устраняет кейс, когда GLB при загрузке остаётся в нулях,
// пока не кликнешь по нему.
const shouldPromote =
(modelTypes[modelId] === "gltf") &&
nonModel.length === 1 &&
(!hasModel || isNeutral(modelTransforms.__model__));
const promoteKey = shouldPromote ? nonModel[0][0] : null;
if (shouldPromote) {
const tr = nonModel[0][1];
const model = sceneModels[modelId];
if (tr && model) {
if (tr.position) model.position = [...tr.position];
if (tr.rotation) model.rotation = [...tr.rotation];
if (tr.scale) model.scale = [...tr.scale];
applied = true;
}
}
// обычное применение: __model__ + остальные объекты (кроме промотнутого root, чтобы не удвоить эффект)
entries.forEach(([objectId, tr]) => {
if (!tr) return;
if (promoteKey && objectId === promoteKey) return;
if (shouldPromote && objectId === "__model__" && isNeutral(tr)) return;
const target =
objectId === "__model__"
? sceneModels[modelId]
: resolveEntity(objectId, modelId);
const applyTarget = target || sceneModels[modelId];
if (!applyTarget) return;
if (tr.position) applyTarget.position = [...tr.position];
if (tr.rotation) applyTarget.rotation = [...tr.rotation];
if (tr.scale) applyTarget.scale = [...tr.scale];
applied = true;
});
if (applied) {
viewer.scene?.setAABBDirty?.();
viewer.scene?.glRedraw?.();
}
return applied;
};
const getModelIdFromNode = (node) => {
const title = (node?.title || node?.text || node?.name || "").trim();
const firstObject = (node && collectObjectIds(node)[0]) || null;
return node?.modelId || node?.model?.id || node?.modelIdGiven || findModelIdForObjectId(firstObject) || (title && sceneModels[title] ? title : null);
};
const findModelIdForObjectId = (objectId) => {
if (!objectId || !viewer?.scene?.models) return null;
const models = viewer.scene.models;
for (const mid of Object.keys(models)) {
if (models[mid]?.objects && Object.prototype.hasOwnProperty.call(models[mid].objects, objectId)) {
return mid;
}
}
return null;
};
const highlightTreeNode = (treeViewNode, { fromTree = false } = {}) => {
if (!treeViewNode || !viewer) return;
const objectIds = collectObjectIds(treeViewNode);
const modelId = getModelIdFromNode(treeViewNode) || findModelIdForObjectId(objectIds[0]);
if (treeContainer) {
const prev = treeContainer.querySelector(".highlighted-node");
if (prev) prev.classList.remove("highlighted-node");
const nodeId = treeViewNode.nodeId || treeViewNode.id;
const liEl = nodeId ? treeContainer.querySelector(`li#${CSS.escape(nodeId)}`) : null;
if (liEl) {
liEl.classList.add("highlighted-node");
}
}
if (!fromTree) {
setSelection(objectIds, modelId);
}
};
const flyToObjectIds = (ids, modelId = null) => {
if (!viewer) return;
const scene = viewer.scene;
const idsToUse = (ids && ids.length) ? ids : (selectionState?.ids || []);
console.log("[DC Tree] flyToObjectIds start", {
count: idsToUse.length,
sample: idsToUse.slice(0, 10),
modelId
});
// фильтруем только существующие в сцене
const existing = idsToUse.filter((id) => scene?.objects?.[id]);
if (existing.length !== idsToUse.length) {
console.warn("[DC Tree] filtered missing ids", {
requested: idsToUse.length,
existing: existing.length
});
}
const idsForAABB = existing.length ? existing : idsToUse;
let aabb = null;
if (idsForAABB.length && scene?.getAABB) {
aabb = scene.getAABB(idsForAABB);
} else if (modelId && scene?.models?.[modelId]) {
aabb = scene.models[modelId].aabb || scene.getAABB({ ids: Object.keys(scene.models[modelId].objects || {}) });
}
const aabbArray = Array.from(aabb || []);
console.log("[DC Tree] AABB", aabbArray);
if (!aabbArray.length || aabbArray.length !== 6 || !aabbArray.every((n) => Number.isFinite(n))) {
console.warn("[DC Tree] no valid AABB for ids", { count: idsForAABB.length });
return;
}
const center = centerFromAABB(aabbArray);
if (viewer.cameraControl) {
viewer.cameraControl.pivotPos = center;
}
viewer.cameraFlight.flyTo({ aabb: aabbArray, duration: 0.6 });
rearmCameraControl();
};
const clearCustomTrees = () => {
customTrees.forEach((el) => el.remove());
customTrees.clear();
};
const renderFallbackTree = (modelId) => {
if (!treeContainer) return;
if (customTrees.has(modelId)) return;
const model = sceneModels[modelId];
if (!model) return;
const objectIds = Object.keys(model.objects || {});
const root = document.createElement("ul");
root.className = "custom-tree";
const liRoot = document.createElement("li");
liRoot.textContent = modelId;
root.appendChild(liRoot);
if (objectIds.length) {
const ul = document.createElement("ul");
objectIds.slice(0, 2000).forEach((oid) => {
const li = document.createElement("li");
const span = document.createElement("span");
span.textContent = oid;
li.appendChild(span);
ul.appendChild(li);
});
liRoot.appendChild(ul);
}
treeContainer.appendChild(root);
customTrees.set(modelId, root);
};
const hasMetaModel = (modelId) => {
return !!viewer?.metaScene?.metaModels?.[modelId];
};
const destroyAllModels = ({ preserveMeta = false } = {}) => {
activeModels.forEach(({ model, loader }) => {
try {
model?.destroy && model.destroy();
} catch (err) {
console.warn("destroy model failed", err);
}
try {
loader?.destroy && loader.destroy();
} catch (err) {
console.warn("destroy loader failed", err);
}
});
activeModels.length = 0;
Object.keys(sceneModels).forEach((key) => delete sceneModels[key]);
Object.keys(modelTypes).forEach((key) => delete modelTypes[key]);
pointCloudColorSnapshots.clear();
if (!preserveMeta) {
Object.keys(transformStore).forEach((key) => delete transformStore[key]);
}
if (blobUrls.length) {
blobUrls.forEach((u) => {
try {
URL.revokeObjectURL(u);
} catch (_) {
// noop
}
});
blobUrls.length = 0;
}
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = null;
}
clearCustomTrees();
treeModelIds.clear();
try {
if (treeView?.destroy) {
treeView.destroy();
}
} catch (err) {
console.warn("treeView destroy failed", err);
}
treeView = null;
if (treeContainer?.replaceChildren) {
treeContainer.replaceChildren();
} else if (treeContainer) {
treeContainer.innerHTML = "";
}
if (sdkRef && viewer && treeContainer) {
resetTreeView(sdkRef);
}
// сбрасываем метамодели, чтобы не тянуть нейминг между проектами
if (viewer?.metaScene?.metaModels) {
try {
const ids = Object.keys(viewer.metaScene.metaModels);
ids.forEach((mid) => {
if (viewer.metaScene.destroyMetaModel) {
viewer.metaScene.destroyMetaModel(mid);
} else {
delete viewer.metaScene.metaModels[mid];
}
});
} catch (err) {
console.warn("metaScene cleanup failed", err);
}
}
setMeasureActive(false);
gizmo?.clearTarget();
treeView?.unShowNode?.();
clearMeasurements();
clearSectionPlanes();
selectionState = null;
lastSelection = { ids: [], modelId: null };
activeTransformTarget = null;
currentSelection = null;
if (!preserveMeta) {
currentProjectMeta = null;
lastAttemptedProjectId = null;
if (projectNameInput) {
projectNameInput.value = "";
projectNameInput.disabled = true;
}
}
updateNavCubeVisibility();
updatePanelsVisibility();
};
const getModelLabel = (modelId) => {
const entry = activeModels.find((m) => m?.model?.id === modelId);
if (!entry) return modelId;
return entry?.meta?.label || entry?.name || modelId;
};
const rebuildTreeModels = () => {
if (!viewer || !sdkRef) return;
treeModelIds.clear();
clearCustomTrees();
resetTreeView(sdkRef);
activeModels.forEach(({ model, name }) => {
const id = model?.id;
if (!id) return;
const metaPresent = hasMetaModel(id);
if (metaPresent && treeView?.addModel) {
try {
treeView.addModel(id, { rootName: name || id });
treeModelIds.add(id);
} catch (err) {
console.warn("tree addModel (rebuild)", err);
}
}
const hasNodes = treeContainer?.querySelector("li");
if (!metaPresent || !hasNodes) {
renderFallbackTree(id);
}
});
};
const deleteModelById = (modelId) => {
if (!modelId) return false;
const idx = activeModels.findIndex((m) => m?.model?.id === modelId);
if (idx === -1) return false;
const entry = activeModels[idx];
try {
entry.model?.destroy?.();
} catch (err) {
console.warn("destroy model failed", err);
}
try {
entry.loader?.destroy?.();
} catch (err) {
console.warn("destroy loader failed", err);
}
activeModels.splice(idx, 1);
delete sceneModels[modelId];
delete modelTypes[modelId];
pointCloudColorSnapshots.delete(modelId);
delete transformStore[modelId];
treeModelIds.delete(modelId);
if (customTrees.has(modelId)) {
try {
customTrees.get(modelId)?.remove?.();
} catch (_) {}
customTrees.delete(modelId);
}
if (viewer?.metaScene?.metaModels?.[modelId]) {
if (viewer.metaScene.destroyMetaModel) {
try {
viewer.metaScene.destroyMetaModel(modelId);
} catch (err) {
console.warn("meta destroy failed", err);
}
} else {
delete viewer.metaScene.metaModels[modelId];
}
}
if (selectionState?.modelId === modelId) {
selectionState = null;
updateTransformInputs(null);
updateGlobalInputs(null);
}
if (lastSelection?.modelId === modelId) {
lastSelection = { ids: [], modelId: null };
}
if (gizmo?.target?.modelId === modelId) {
gizmo.clearTarget();
}
activeTransformTarget = null;
currentSelection = null;
rebuildTreeModels();
updateNavCubeVisibility();
updatePanelsVisibility();
viewer?.scene?.setAABBDirty?.();
viewer?.scene?.glRedraw?.();
setStatus(`Модель ${modelId} удалена`);
return true;
};
const selectObject = ({ modelId, objectId }) => {
if (!viewer) return;
setSelection(objectId ? [objectId] : [], modelId);
if (objectId && treeView?.showNode) {
treeView.showNode(objectId);
}
};
const resetTreeView = (sdk) => {
if (treeView?.destroy) treeView.destroy();
treeView = new sdk.TreeViewPlugin(viewer, {
containerElement: treeContainer,
hierarchy: "containment",
autoAddModels: false,
pruneEmptyNodes: false,
autoExpandDepth: 2,
});
if (treeView?.on) {
treeView.on("nodeTitleClicked", (e) => {
const node = e?.treeViewNode || e?.node || e;
console.info("[DC Tree] nodeTitleClicked", {
nodeId: node?.nodeId || node?.id,
title: node?.title || node?.text || node?.name,
detail: e?.event?.detail
});
const ids = collectObjectIds(node);
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
setSelection(ids, modelId);
const clickDetail = (e?.event?.detail || e?.detail || 1);
if (clickDetail >= 2) {
flyToObjectIds(ids, modelId);
}
highlightTreeNode(node, { fromTree: true });
});
treeView.on("nodeTitleDoubleClicked", (e) => {
const node = e?.treeViewNode || e?.node || e;
console.info("[DC Tree] nodeTitleDoubleClicked", {
nodeId: node?.nodeId || node?.id,
title: node?.title || node?.text || node?.name
});
const ids = collectObjectIds(node);
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
highlightTreeNode(node, { fromTree: true });
flyToObjectIds(ids, modelId);
});
treeView.on("nodeDoubleClicked", (e) => {
const node = e?.treeViewNode || e?.node || e;
console.info("[DC Tree] nodeDoubleClicked", {
nodeId: node?.nodeId || node?.id,
title: node?.title || node?.text || node?.name
});
const ids = collectObjectIds(node);
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
highlightTreeNode(node, { fromTree: true });
flyToObjectIds(ids, modelId);
});
}
};
const createBlobSafeGLTFDataSource = () => ({
cacheBuster: false,
getGLTF: (src, ok, error) => {
if (src.startsWith("memory:") && memoryStore.has(src)) {
ok(memoryStore.get(src));
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getGLB: (src, ok, error) => {
if (src.startsWith("memory:") && memoryStore.has(src)) {
ok(memoryStore.get(src));
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getMetaModel: (src, ok, error) => {
if (src.startsWith("memory:") && memoryStore.has(src)) {
try {
const buffer = memoryStore.get(src);
const text = new TextDecoder().decode(new Uint8Array(buffer));
ok(JSON.parse(text));
} catch (err) {
error(err.message || err);
}
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(ok)
.catch((err) => error(err.message || err));
},
});
const createMemoryDataSource = () => ({
cacheBuster: false,
getXKT: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getGLTF: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getGLB: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getLAS: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getMetaModel: (src, ok, error) => {
if (memoryStore.has(src)) {
try {
const stored = memoryStore.get(src);
const bytes = cloneToUint8(stored) || new Uint8Array(0);
const text = new TextDecoder().decode(bytes);
ok(JSON.parse(text));
} catch (err) {
error(err.message || err);
}
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getSTL: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
});
const loaderFor = (sdk, type, { cacheBuster = true, dataSource } = {}) => {
switch (type) {
case "xkt":
return new sdk.XKTLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.XKTDefaultDataSource({ cacheBuster }),
});
case "gltf":
return new sdk.GLTFLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.GLTFDefaultDataSource({ cacheBuster }),
});
case "bim":
return new sdk.DotBIMLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.DotBIMDefaultDataSource({ cacheBuster }),
});
case "las":
return new sdk.LASLoaderPlugin(viewer, {
fp64: true,
dataSource: dataSource,
});
case "obj":
return new sdk.OBJLoaderPlugin(viewer);
case "stl":
return new sdk.STLLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.STLDefaultDataSource({ cacheBuster }),
});
default:
throw new Error(`Неподдерживаемый тип: ${type}`);
}
};
const guessTypeFromName = (name) => {
if (!name) return null;
const lower = name.toLowerCase();
if (lower.endsWith(".xkt")) return "xkt";
if (lower.endsWith(".glb") || lower.endsWith(".gltf")) return "gltf";
if (lower.endsWith(".bim")) return "bim";
if (lower.endsWith(".las") || lower.endsWith(".laz")) return "las";
if (lower.endsWith(".obj")) return "obj";
if (lower.endsWith(".stl")) return "stl";
return null;
};
const isApiRequestUrl = (url) => {
try {
return new URL(url, window.location.href).pathname.startsWith("/api/");
} catch (_) {
return String(url || "").startsWith("/api/");
}
};
const formatFetchFailureMessage = (url, res, text, data) => {
const apiRequest = isApiRequestUrl(url);
if (apiRequest && res.status === 401) {
return "BIM API не принял запрос: нужна авторизация.";
}
if (apiRequest && res.status === 404) {
return "BIM API недоступен на этом адресе. Открой viewer через node server/index.js, не через статический http-server.";
}
if (apiRequest && /^\s* {
const status = res?.status ? `HTTP ${res.status}` : "HTTP";
if (/^\s* {
const apiRequest = isApiRequestUrl(url);
let res = null;
try {
res = await fetch(url, options);
} catch (err) {
if (apiRequest) {
throw new Error(`BIM API недоступен: ${err?.message || "network error"}`);
}
throw err;
}
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (err) {
// ignore parse errors, text will be used for message
}
if (!res.ok) {
const message = formatFetchFailureMessage(url, res, text, data);
throw new Error(message);
}
if (apiRequest && text && !data) {
throw new Error(formatApiNonJsonMessage(url, res, text));
}
return data;
};
const fetchJSONWithTimeout = async (url, options = {}, timeoutMs = 5000) => {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetchJSON(url, {
...options,
signal: options.signal || controller.signal,
});
} finally {
window.clearTimeout(timer);
}
};
const uploadBuffer = async (bytes, filename) => {
const safeName = filename || "upload.bin";
const params = new URLSearchParams({ filename: safeName });
if (currentProjectMeta?.id) {
params.set("projectId", currentProjectMeta.id);
}
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.src;
};
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 baseModel = {
id: model.id,
type: modelType,
meta: {
label: meta.label || entry.name || model.id,
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 uploadedSrc = await uploadBuffer(bytes, filename);
uploadCache.set(cacheKey, uploadedSrc);
models.push({
...baseModel,
src: uploadedSrc
});
}
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,
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 || !settingsMenu?.applyState) return;
settingsMenu.applyState(design);
applyToolbarVars();
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,
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;
}
if (typeof viewerState.displayMode === "string") {
activeDisplayMode = viewerState.displayMode;
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;
}
if (typeof state.displayMode === "string") {
activeDisplayMode = state.displayMode;
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 () => {
// remove preload hidden style ASAP
const preloadHidden = document.getElementById("preload-hidden");
if (preloadHidden) {
preloadHidden.remove();
}
document.body.style.opacity = "1";
await initializeRuntimeMode();
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);
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();
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();
// если есть сохранённые трансформы для модели — применим
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");
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");
return;
}
dropZone?.classList.add("hidden");
setStatus(`Подготовка: ${name}...`);
setLoading(true);
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();
}
if (params.has("lightingMode") || params.has("light")) {
activeLightingMode = params.get("lightingMode") || params.get("light") || "ambient";
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,
versionId: sharedViewer?.versionId || null,
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();
}
focusCanvasAndControls();
},
onShow: () => {
bringFloatingWindowToFront("settings");
setToolbarButtonActive(settingsButton, true);
setToolbarButtonActive(templatesButton, false);
closeTemplatesPanel();
},
onHide: () => {
setToolbarButtonActive(settingsButton, false);
}
});
// ensure persisted settings are applied to cube/highlight on first load
rebuildNavCube(sdk);
applyHighlightTheme();
applyMeasureTheme();
applySectionPlaneTheme();
if (displayModeControl && displayModeSelectHost) {
displayModeSelect = createGlassSelect({
host: displayModeSelectHost,
value: activeDisplayMode,
options: DISPLAY_MODE_OPTIONS,
ariaLabel: "Режим отображения",
onChange: (value) => {
activeDisplayMode = value;
applyDisplayMode();
focusCanvasAndControls();
},
});
setDisplayModeControlState();
}
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");
});
registerFloatingWindowElement("inspector", sidePanelEl);
registerFloatingWindowElement("comments", commentsPanel);
registerFloatingWindowElement("settings", settingsPanel);
registerFloatingWindowElement("templates", templatesPanel);
registerFloatingWindowElement("commentModal", commentModal);
setupDraggableFixedWindow(commentsPanel, commentsPanelHeader);
setupDraggableFixedWindow(commentModal, commentModalHeader);
setupResizableFixedWindow(commentModal, { minWidth: 560, 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 = (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);
const urlKey = `memory:${crypto.randomUUID ? crypto.randomUUID() : Date.now()}`;
file.arrayBuffer().then((buf) => {
// Храним копию буфера, чтобы загрузчик не детачил исходный
const bytes = cloneToUint8(buf) || new Uint8Array(buf.byteLength || 0);
memoryStore.set(urlKey, bytes);
loadModel({ type: detected, url: urlKey, name: file.name, replace: !addMode.checked });
}).catch((err) => {
console.error(err);
showError(err.message || err);
});
};
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", () => {
destroyAllModels();
dropZone.classList.remove("hidden");
setStatus("Готов к загрузке");
modelCounter = 0;
setLoading(false);
memoryStore.clear();
currentSelection = null;
updateTransformInputs(null);
templatesMenu?.hide();
setMeasureActive(false);
clearMeasurements();
closeSaveModal();
closeDeleteModal();
// Примеры доступны, но сцена чистая
gizmo?.clearTarget();
updateNavCubeVisibility();
updatePanelsVisibility();
});
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) {
bottomToolbar.addEventListener("click", (e) => {
const btn = e.target.closest(".tb-btn");
if (!btn) return;
if (isGuestMode()) return;
const action = btn.dataset.action;
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) => {
if (isGuestMode()) return;
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];
}