const SDK_VERSION = "2.6.78"; // соответствует package.json
const SDK_URL = "./lib/dc-camera-context.es.js";

import { createLogo } from "./src/ui/logo.js";
import { toolbarConfig, applyToolbarVars } from "./src/ui/toolbar.js";
import { createTemplatesMenu } from "./src/ui/templatesMenu.js";
import { createSettingsMenu } from "./src/ui/settingsMenu.js";
import { createMeasurementModal } from "./src/ui/measurementModal.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 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 projectionToggle = document.querySelector('[data-action="projection"]');
const treeContainer = document.getElementById("treeContainer");
const displayModeControl = document.getElementById("displayModeControl");
const displayModeButtons = document.querySelectorAll("[data-display-mode]");
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 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 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 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
}));
let templatesMenu = null;
let settingsMenu = null;
let measurementModal = null;

applyPanelOffsets();

let viewer = null;
let treeView = null;
let currentBlobUrl = null;
let modelCounter = 0;
const activeModels = [];
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
let activeDisplayMode = "source";
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 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 ensureCameraControlActive = () => {
  if (!viewer || !viewer.cameraControl) return;
  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 setDisplayModeButtonState = () => {
  displayModeButtons.forEach((button) => {
    button.classList.toggle("active", button.dataset.displayMode === activeDisplayMode);
  });
};

const getDisplayModeColorize = (mode) => {
  switch (mode) {
    case "white":
      return [1, 1, 1];
    case "contrast":
      return [0.78, 0.82, 1];
    case "source":
    default:
      return null;
  }
};

const applyDisplayModeToModel = (modelId) => {
  const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId];
  if (!model) return;
  const colorize = getDisplayModeColorize(activeDisplayMode);
  const objects = Object.values(model.objects || {});
  const targets = objects.length ? objects : [model];

  targets.forEach((entity) => {
    if (!entity) return;
    try {
      entity.colorize = colorize;
      entity.opacity = 1;
      if (activeDisplayMode !== "source") {
        entity.edges = false;
      }
    } catch (err) {
      console.warn("display mode update failed", err);
    }
  });

  viewer?.scene?.glRedraw?.();
};

const applyDisplayMode = () => {
  setDisplayModeButtonState();
  Object.keys(sceneModels).forEach((modelId) => applyDisplayModeToModel(modelId));
};

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();
  setToolbarButtonActive(settingsButton, true);
};

const openTemplatesPanel = () => {
  closeSettingsPanel();
  templatesMenu?.show();
  setToolbarButtonActive(templatesButton, true);
};

const cssValue = (name, fallback = "") => {
  const styles = getComputedStyle(document.documentElement);
  const v = styles.getPropertyValue(name);
  return (v && v.trim()) || fallback;
};

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 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 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 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 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.style.display = hasModels ? "flex" : "none";
  if (treeContainer) treeContainer.classList.toggle("hidden", !hasModels);
  const showActions = hasModels || !!currentProjectMeta || !!lastAttemptedProjectId;
  if (saveProjectSection) {
    saveProjectSection.classList.toggle("hidden", !showActions);
  }
  if (projectNameSection) {
    projectNameSection.classList.toggle("hidden", !(currentProjectMeta || lastAttemptedProjectId));
  }
  if (saveProjectButton) {
    saveProjectButton.disabled = !hasModels;
  }
  if (deleteProjectButton) {
    deleteProjectButton.disabled = !(currentProjectMeta || lastAttemptedProjectId);
  }
  if (projectNameInput) {
    const hasProject = !!(currentProjectMeta || lastAttemptedProjectId);
    projectNameInput.disabled = !hasProject;
    if (!hasProject) {
      projectNameInput.value = "";
    }
  }
};

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);
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);
  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]);
  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();
  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];
  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 fetchJSON = async (url, options = {}) => {
  const res = await fetch(url, options);
  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 = (data && data.message) || text || `HTTP ${res.status}`;
    throw new Error(message);
  }
  return data;
};

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 (!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
      },
      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,
    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 applyViewerState = (state) => {
  if (!viewer || !state || typeof state !== "object") return;
  if (state.camera && viewer.camera) {
    const { eye, look, up, projection } = state.camera;
    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();
  }
  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 (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";

  const sdk = await import(SDK_URL);
  sdkRef = sdk;
  const { Viewer, NavCubePlugin, DistanceMeasurementsPlugin, DistanceMeasurementsMouseControl } = 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;
    };
  }

  viewer = new Viewer({
    canvasId: "viewerCanvas",
    transparent: true,
    dtxEnabled: true,
    colorTextureEnabled: true,
  });
  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 (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;
  }
  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;
    canvasEl.addEventListener("pointerdown", (event) => {
      if (measureActive) {
        downPos = null;
        return;
      }
      downPos = { x: event.clientX, y: event.clientY };
    });
    canvasEl.addEventListener("click", (event) => {
      if (measureActive) {
        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) => {
    try {
      const { type, url, name = "", replace = true, id: forcedId, meta, edges } = options;
      const normalizedType = normalizeType(type, typeof url === "string" ? url : "");
      const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : ""));
      if (!inferredType || !acceptByType[inferredType]) {
        showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl");
        return null;
      }

      if (replace) {
        destroyAllModels({ preserveMeta: isLoadingSavedProject });
      }

      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);
      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" ? 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", () => {
        setTimeout(() => applyStoredTransformsToModel(id), 0);
        setTimeout(() => applyStoredTransformsToModel(id), 60);
        if (!isLoadingSavedProject) {
          viewer.cameraFlight.flyTo({ aabb: viewer.scene.getAABB({}) });
        }
        setStatus(`Загружено: ${name || inferredType}`);
        dropZone.classList.add("hidden");
        setLoading(false);
        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) => {
        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);
      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 = () => {
    const params = new URLSearchParams(window.location.search);
    const src = params.get("url") || params.get("src");
    if (!src) return;

    const name = params.get("name") || src.split("/").pop() || "model";
    const type = normalizeType(params.get("type"), src) || normalizeType(guessTypeFromName(name));
    if (!type) {
      showError("Не удалось определить формат модели из URL");
      return;
    }
    if (params.has("displayMode")) {
      activeDisplayMode = params.get("displayMode") || "source";
      setDisplayModeButtonState();
    }

    loadModel({
      type,
      url: src,
      name,
      replace: params.get("replace") !== "false",
      edges: params.has("edges") ? params.get("edges") !== "false" : undefined,
      meta: {
        label: name,
        source: "query",
      },
    });
  };

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 (!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 (!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 () => {
    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 (!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: () => {
      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();
      }
      focusCanvasAndControls();
    },
    onShow: () => {
      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();
  if (displayModeControl) {
    displayModeButtons.forEach((button) => {
      button.addEventListener("click", () => {
        activeDisplayMode = button.dataset.displayMode || "source";
        applyDisplayMode();
        focusCanvasAndControls();
      });
    });
    setDisplayModeButtonState();
  }
  loadProjectsList().catch(() => {
    templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" });
  });

  const handleFile = (file) => {
    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) => {
        e.preventDefault();
        e.stopPropagation();
        dropZone.classList.add("active");
      });
    });

    ["dragleave", "drop"].forEach((evt) => {
      viewerWrapper.addEventListener(evt, (e) => {
        e.preventDefault();
        e.stopPropagation();
        dropZone.classList.remove("active");
      });
    });

    viewerWrapper.addEventListener("drop", (e) => {
      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);
  }
  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();
    }
  });

  document.addEventListener("keydown", (e) => {
    if (e.key !== "Escape") return;
    if (e.target && ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) 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: () => {}
    });
  }

  loadStartupModelFromQuery();
};

  document.addEventListener("DOMContentLoaded", () => {
    initViewer();
  });

// нижняя тулбар
  if (bottomToolbar) {
    bottomToolbar.addEventListener("click", (e) => {
      const btn = e.target.closest(".tb-btn");
      if (!btn) 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 === "projection") {
        toggleProjection();
      } else if (action === "add") {
        fileInput.click();
      }
    });
  }

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];
}
