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=2";
import { createSettingsMenu } from "./src/ui/settingsMenu.js?v=9";
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 = `
  <svg viewBox="0 0 24 24" aria-hidden="true">
    <path d="M6.5 4.75h11A3.25 3.25 0 0 1 20.75 8v6.25a3.25 3.25 0 0 1-3.25 3.25h-5.26l-5.29 3.82V17.5H6.5a3.25 3.25 0 0 1-3.25-3.25V8A3.25 3.25 0 0 1 6.5 4.75Z"></path>
  </svg>
`;

const formatDateTime = (value) => {
  if (!value) return "";
  try {
    return new Intl.DateTimeFormat("ru-RU", {
      day: "2-digit",
      month: "2-digit",
      year: "2-digit",
      hour: "2-digit",
      minute: "2-digit",
    }).format(new Date(value));
  } catch (_) {
    return String(value);
  }
};

const setCommentModalError = (message = "") => {
  if (!commentModalError) return;
  const text = String(message || "").trim();
  commentModalError.textContent = text;
  commentModalError.hidden = !text;
};

const 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 = `
        <svg viewBox="0 0 24 24" aria-hidden="true">
          <path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z"></path>
          <path d="M14 2v5h5"></path>
        </svg>
      `;
      const meta = document.createElement("div");
      meta.className = "comment-attachment-file__meta";
      const name = document.createElement("strong");
      name.textContent = attachment.name || "Файл";
      const size = document.createElement("span");
      size.textContent = formatFileSize(attachment.size) || attachment.type || "Файл";
      meta.append(name, size);
      file.appendChild(meta);
      thumb.appendChild(file);
    }
    const remove = document.createElement("button");
    remove.type = "button";
    remove.setAttribute("aria-label", "Удалить файл");
    remove.textContent = "×";
    remove.addEventListener("click", () => onRemove?.(index));
    thumb.appendChild(remove);
    grid.appendChild(thumb);
  });
};

const renderThreadAttachments = (container, attachments) => {
  if (!attachments?.length) return;
  const grid = document.createElement("div");
  grid.className = "comment-attachment-grid";
  attachments.forEach((attachment) => {
    const thumb = document.createElement("div");
    thumb.className = "comment-attachment-thumb";
    const isImage = String(attachment.type || "").startsWith("image/") || /^data:image\//i.test(String(attachment.dataUrl || ""));
    if (isImage) {
      const img = document.createElement("img");
      img.src = attachment.dataUrl;
      img.alt = attachment.name || "attachment";
      thumb.appendChild(img);
    } else {
      const file = document.createElement("div");
      file.className = "comment-attachment-file";
      file.innerHTML = `
        <svg viewBox="0 0 24 24" aria-hidden="true">
          <path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z"></path>
          <path d="M14 2v5h5"></path>
        </svg>
      `;
      const meta = document.createElement("div");
      meta.className = "comment-attachment-file__meta";
      const name = document.createElement("strong");
      name.textContent = attachment.name || "Файл";
      const size = document.createElement("span");
      size.textContent = formatFileSize(attachment.size) || attachment.type || "Файл";
      meta.append(name, size);
      file.appendChild(meta);
      thumb.appendChild(file);
    }
    grid.appendChild(thumb);
  });
  container.appendChild(grid);
};

const renderCommentThread = (comment) => {
  if (!commentThreadList) return;
  commentThreadList.replaceChildren();
  const messages = Array.isArray(comment?.messages) ? comment.messages : [];
  if (!messages.length) {
    const empty = document.createElement("div");
    empty.className = "comment-thread-empty";
    empty.textContent = "Комментариев пока нет";
    commentThreadList.appendChild(empty);
    return;
  }
  messages.forEach((message) => {
    const item = document.createElement("div");
    item.className = "comment-thread-message";
    const head = document.createElement("div");
    head.className = "comment-thread-message__head";
    const author = document.createElement("strong");
    author.textContent = message?.createdBy?.name || message?.createdBy?.email || "Пользователь";
    const date = document.createElement("span");
    date.textContent = formatDateTime(message?.updatedAt || message?.createdAt);
    head.append(author, date);
    const body = document.createElement("div");
    body.className = "comment-thread-message__body";
    body.textContent = message?.body || "";
    item.append(head, body);
    renderThreadAttachments(item, message?.attachments || []);
    commentThreadList.appendChild(item);
  });
};

const resetCommentModalAttachments = () => {
  commentDraftAttachments = [];
  commentReplyAttachments = [];
  commentDraftSubitems = [];
  renderModalAttachmentInputs();
  renderCommentSubitems();
};

const renderModalAttachmentInputs = () => {
  renderAttachmentGrid(commentAttachmentGrid, commentDraftAttachments, (index) => {
    commentDraftAttachments.splice(index, 1);
    renderModalAttachmentInputs();
  });
  renderAttachmentGrid(commentReplyAttachmentGrid, commentReplyAttachments, (index) => {
    commentReplyAttachments.splice(index, 1);
    renderModalAttachmentInputs();
  });
};

const setCommentModalExpanded = (expanded) => {
  commentModalExpanded = !!expanded;
  commentModal?.classList.toggle("is-expanded", commentModalExpanded);
  if (commentModalExpandButton) {
    commentModalExpandButton.setAttribute("aria-label", commentModalExpanded ? "Свернуть" : "Развернуть");
    commentModalExpandButton.title = commentModalExpanded ? "Свернуть" : "Развернуть";
  }
};

const hideCommentSubitemMenu = () => {
  commentSubitemMenu?.classList.add("hidden");
};

const showCommentSubitemMenu = () => {
  if (!commentSubitemMenu || !commentAddSubitemButton) return;
  if (commentSubitemMenu.parentElement !== document.body) {
    document.body.appendChild(commentSubitemMenu);
  }
  commentSubitemMenu.classList.remove("hidden");
  const buttonRect = commentAddSubitemButton.getBoundingClientRect();
  const width = commentSubitemMenu.offsetWidth || 276;
  const x = Math.max(8, Math.min(window.innerWidth - width - 8, buttonRect.left));
  const y = buttonRect.bottom + 8;
  commentSubitemMenu.style.left = `${x}px`;
  commentSubitemMenu.style.top = `${y}px`;
};

const toggleCommentSubitemMenu = () => {
  if (!commentSubitemMenu) return;
  if (commentSubitemMenu.classList.contains("hidden")) {
    showCommentSubitemMenu();
  } else {
    hideCommentSubitemMenu();
  }
};

const addCommentSubitem = (kind) => {
  const nextKind = kind === "text" ? "text" : "checker";
  commentDraftSubitems.push({
    id: `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`,
    kind: nextKind,
    title: "",
    body: "",
    done: false,
  });
  renderCommentSubitems();
  window.requestAnimationFrame(() => {
    const selector = nextKind === "text"
      ? ".ops-comment-subitem[data-kind='text'] textarea"
      : ".ops-comment-subitem[data-kind='checker'] input[type='text']";
    const inputs = commentSubitemsList?.querySelectorAll?.(selector);
    inputs?.[inputs.length - 1]?.focus?.();
  });
};

const renderCommentSubitems = () => {
  if (!commentSubitemsList || !commentSubitemsSection) return;
  commentSubitemsList.replaceChildren();
  const subitems = Array.isArray(commentDraftSubitems) ? commentDraftSubitems : [];
  commentSubitemsSection.classList.toggle("hidden", !subitems.length);
  subitems.forEach((subitem, index) => {
    const kind = subitem.kind === "text" ? "text" : "checker";
    const row = document.createElement(kind === "text" ? "div" : "label");
    row.className = "ops-comment-subitem";
    row.dataset.kind = kind;
    let editor = null;
    if (kind === "text") {
      editor = document.createElement("textarea");
      editor.rows = 3;
      editor.value = subitem.body || subitem.title || "";
      editor.placeholder = "Текстовый блок";
      editor.addEventListener("input", () => {
        commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, body: editor.value, title: editor.value };
      });
    } else {
      const checkbox = document.createElement("input");
      checkbox.type = "checkbox";
      checkbox.checked = !!subitem.done;
      checkbox.addEventListener("change", () => {
        commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, done: checkbox.checked };
      });
      editor = document.createElement("input");
      editor.type = "text";
      editor.value = subitem.title || subitem.body || "";
      editor.placeholder = "Новый чекер";
      editor.addEventListener("input", () => {
        commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, title: editor.value };
      });
      row.appendChild(checkbox);
    }
    const remove = document.createElement("button");
    remove.type = "button";
    remove.setAttribute("aria-label", "Удалить подэлемент");
    remove.textContent = "×";
    remove.addEventListener("click", () => {
      commentDraftSubitems.splice(index, 1);
      renderCommentSubitems();
    });
    row.append(editor, remove);
    commentSubitemsList.appendChild(row);
  });
};

const openCommentModalForTarget = () => {
  if (!commentContextTarget || !commentModal) return;
  hideCommentSubitemMenu();
  commentsModeActive = true;
  commentsCollapsed = false;
  commentModalMode = "create";
  commentModalCommentId = null;
  activeCommentId = "__draft_comment__";
  commentTitleInput && (commentTitleInput.value = "");
  commentBodyInput && (commentBodyInput.value = "");
  commentReplyInput && (commentReplyInput.value = "");
  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*<!doctype html/i.test(text || "")) {
    return `BIM API вернул HTML вместо JSON (HTTP ${res.status}). Проверь, что открыт backend-сервер BIM Viewer.`;
  }
  return (data && (data.message || data.error)) || text || `HTTP ${res.status}`;
};

const formatApiNonJsonMessage = (url, res, text) => {
  const status = res?.status ? `HTTP ${res.status}` : "HTTP";
  if (/^\s*<!doctype html/i.test(text || "")) {
    return `BIM API на этом адресе вернул HTML вместо JSON (${status}). На localhost:8080 сейчас нет backend /api/comments.`;
  }
  return `BIM API вернул не-JSON ответ (${status}).`;
};

const fetchJSON = async (url, options = {}) => {
  const apiRequest = isApiRequestUrl(url);
  let res = null;
  try {
    res = await fetch(url, options);
  } catch (err) {
    if (apiRequest) {
      throw new Error(`BIM API недоступен: ${err?.message || "network error"}`);
    }
    throw err;
  }
  const text = await res.text();
  let data = null;
  try {
    data = text ? JSON.parse(text) : null;
  } catch (err) {
    // ignore parse errors, text will be used for message
  }
  if (!res.ok) {
    const message = formatFetchFailureMessage(url, res, text, data);
    throw new Error(message);
  }
  if (apiRequest && text && !data) {
    throw new Error(formatApiNonJsonMessage(url, res, text));
  }
  return data;
};

const fetchJSONWithTimeout = async (url, options = {}, timeoutMs = 5000) => {
  const controller = new AbortController();
  const timer = window.setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetchJSON(url, {
      ...options,
      signal: options.signal || controller.signal,
    });
  } finally {
    window.clearTimeout(timer);
  }
};

const uploadBuffer = async (bytes, filename) => {
  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];
}
