NODEDC_BIM_VIEWER/frontend/dcViewer.js

4616 lines
155 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

const SDK_VERSION = "2.6.78"; // соответствует package.json
const SDK_URL = "./lib/dc-camera-context.es.js?v=2";
import { createLogo } from "./src/ui/logo.js";
import { toolbarConfig, applyToolbarVars } from "./src/ui/toolbar.js";
import { createTemplatesMenu } from "./src/ui/templatesMenu.js?v=2";
import { createSettingsMenu } from "./src/ui/settingsMenu.js?v=6";
import { createMeasurementModal } from "./src/ui/measurementModal.js";
import { createGlassSelect } from "./src/ui/glassSelect.js";
import { createSliderControl } from "./src/ui/controls/slider.js";
import "./theme.js";
import { TransformGizmo } from "./transformGizmo.js";
import { createObjectModal } from "./src/ui/objectModal.js";
const dropZone = document.getElementById("dropZone");
const viewerWrapper = document.querySelector(".viewer-wrapper");
const headerEl = document.querySelector("header");
const sidePanelEl = document.querySelector(".side-panel");
const inspectorCollapseButton = document.getElementById("inspectorCollapseButton");
const inspectorRestoreTray = document.getElementById("inspectorRestoreTray");
const inspectorRestoreButton = document.getElementById("inspectorRestoreButton");
const fileInput = document.getElementById("fileInput");
const chooseFileBtn = document.getElementById("chooseFile");
const examplesBar = document.getElementById("examplesBar");
const templatesPanel = document.getElementById("templatesPanel");
const settingsPanel = document.getElementById("settingsPanel");
const measureModalBackdrop = document.getElementById("measureModalBackdrop");
const measureModal = document.getElementById("measureModal");
const objectModalBackdrop = document.getElementById("objectModalBackdrop");
const objectModal = document.getElementById("objectModal");
const statusText = document.getElementById("statusText");
const fitViewBtn = document.getElementById("fitView");
const toggleEdges = document.getElementById("toggleEdges");
const toggleTransparent = document.getElementById("toggleTransparent");
const addMode = document.getElementById("addMode");
const homeButton = document.getElementById("homeButton");
const bottomToolbar = document.getElementById("bottomToolbar");
const measureButton = document.querySelector('[data-action="measure"]');
const settingsButton = document.querySelector('[data-action="settings"]');
const templatesButton = document.querySelector('[data-action="templates"]');
const shareButton = document.querySelector('[data-action="share"]');
const sectionButton = document.querySelector('[data-action="section"]');
const sectionToolbar = document.getElementById("sectionToolbar");
const sectionOverviewCanvas = document.getElementById("sectionPlanesOverview");
const projectionToggle = document.querySelector('[data-action="projection"]');
const treeContainer = document.getElementById("treeContainer");
const displayModeControl = document.getElementById("displayModeControl");
const displayModeSelectHost = document.getElementById("displayModeSelect");
const lightingModeControl = document.getElementById("lightingModeControl");
const lightingModeSelectHost = document.getElementById("lightingModeSelect");
const navigationAxisControl = document.getElementById("navigationAxisControl");
const navigationAxisSelectHost = document.getElementById("navigationAxisSelect");
const zoomSpeedControl = document.getElementById("zoomSpeedControl");
const logList = document.getElementById("logList");
const loaderEl = document.getElementById("loader");
const navCubeCanvas = document.getElementById("navCube");
const mainCanvas = document.getElementById("viewerCanvas");
const saveProjectSection = document.getElementById("saveProjectSection");
const saveProjectButton = document.getElementById("saveProjectButton");
const saveModelSettingsButton = document.getElementById("saveModelSettingsButton");
const saveProjectBackdrop = document.getElementById("saveProjectBackdrop");
const saveProjectModal = document.getElementById("saveProjectModal");
const saveProjectClose = document.getElementById("saveProjectClose");
const saveProjectCancel = document.getElementById("saveProjectCancel");
const saveProjectConfirm = document.getElementById("saveProjectConfirm");
const saveProjectDuplicate = document.getElementById("saveProjectDuplicate");
const saveProjectNameInput = document.getElementById("saveProjectName");
const saveProjectError = document.getElementById("saveProjectError");
const saveProjectHint = document.getElementById("saveProjectHint");
const deleteProjectButton = document.getElementById("deleteProjectButton");
const deleteProjectBackdrop = document.getElementById("deleteProjectBackdrop");
const deleteProjectModal = document.getElementById("deleteProjectModal");
const deleteProjectClose = document.getElementById("deleteProjectClose");
const deleteProjectCancel = document.getElementById("deleteProjectCancel");
const deleteProjectConfirm = document.getElementById("deleteProjectConfirm");
const deleteProjectError = document.getElementById("deleteProjectError");
const shareModalBackdrop = document.getElementById("shareModalBackdrop");
const shareModal = document.getElementById("shareModal");
const shareModalClose = document.getElementById("shareModalClose");
const shareModalCancel = document.getElementById("shareModalCancel");
const shareLinkInput = document.getElementById("shareLinkInput");
const shareCopyButton = document.getElementById("shareCopyButton");
const shareError = document.getElementById("shareError");
const projectNameSection = document.getElementById("projectNameSection");
const projectNameInput = document.getElementById("projectNameInput");
const projectNameError = document.getElementById("projectNameError");
const transformInputs = {
posX: document.getElementById("posX"),
posY: document.getElementById("posY"),
posZ: document.getElementById("posZ"),
rotX: document.getElementById("rotX"),
rotY: document.getElementById("rotY"),
rotZ: document.getElementById("rotZ"),
scaleX: document.getElementById("scaleX"),
scaleY: document.getElementById("scaleY"),
scaleZ: document.getElementById("scaleZ"),
};
const globalInputs = {
x: document.getElementById("globalX"),
y: document.getElementById("globalY"),
z: document.getElementById("globalZ"),
};
const modeButtons = document.querySelectorAll("[data-mode]");
const PANEL_OFFSETS = {
templates: { left: 52, bottom: 12 },
settings: { left: 52, bottom: 12 },
measurement: { left: 52, bottom: 12 },
};
const applyPanelOffsets = () => {
const root = document.documentElement?.style;
if (!root) return;
root.setProperty("--templates-left", `${PANEL_OFFSETS.templates.left}px`);
root.setProperty("--templates-bottom", `${PANEL_OFFSETS.templates.bottom}px`);
root.setProperty("--settings-left", `${PANEL_OFFSETS.settings.left}px`);
root.setProperty("--settings-bottom", `${PANEL_OFFSETS.settings.bottom}px`);
root.setProperty("--measure-left", `${PANEL_OFFSETS.measurement.left}px`);
root.setProperty("--measure-bottom", `${PANEL_OFFSETS.measurement.bottom}px`);
};
const API_BASE = "/api/projects";
const MODEL_SETTINGS_API = "/api/model-settings";
const AUTH_SESSION_API = "/api/auth/session";
const SHARES_API = "/api/shares";
const acceptByType = {
xkt: ".xkt",
gltf: ".glb,.gltf",
glb: ".glb,.gltf",
bim: ".bim",
las: ".las,.laz",
laz: ".las,.laz",
obj: ".obj",
stl: ".stl",
};
const DEFAULT_EXAMPLES = [
{
id: "ifc-xkt",
name: "IFC / XKT пример",
description: "Стандартная BIM-модель Schependomlaan.",
type: "xkt",
url: "./examples/ifc-xkt/Schependomlaan.ifc.xkt",
},
{
id: "glb",
name: "GLB пример",
description: "Городской фрагмент MAP в формате glTF/GLB.",
type: "gltf",
url: "./examples/glb/MAP.glb",
},
{
id: "pointcloud",
name: "Point Cloud пример",
description: "Облако точек Nalls Pumpkin Hill (LAZ).",
type: "las",
url: "./examples/pointcloud/Nalls_Pumpkin_Hill.laz",
},
{
id: "bim",
name: "BIM (.bim) пример",
description: "Тестовая BIM-модель BlenderHouse в формате .bim.",
type: "bim",
url: "./examples/bim/BlenderHouse.bim",
},
];
const templateFallbacks = DEFAULT_EXAMPLES.map((ex) => ({
id: ex.id,
name: ex.name,
type: "template",
updatedAt: null
}));
const DISPLAY_MODE_OPTIONS = [
{ value: "source", label: "Исходный" },
{ value: "clay", label: "Clay" },
{ value: "white", label: "Белый" },
{ value: "contrast", label: "Контраст" },
{ value: "edges", label: "Рёбра" },
{ value: "xray", label: "Рентген" },
];
const NAVIGATION_AXIS_OPTIONS = [
{ value: "auto", label: "Авто" },
{ value: "x-up", label: "X" },
{ value: "y-up", label: "Y" },
{ value: "z-up", label: "Z" },
];
const LIGHTING_MODE_OPTIONS = [
{ value: "standard", label: "Стандарт" },
{ value: "soft", label: "Мягкий" },
{ value: "ambient", label: "Без солнца" },
{ value: "technical", label: "Тех." },
];
const NAVIGATION_AXIS_PRESETS = {
"y-up": {
worldAxis: [1, 0, 0, 0, 1, 0, 0, 0, 1],
up: [0, 1, 0],
forward: [0, 0, 1],
},
"z-up": {
worldAxis: [1, 0, 0, 0, 0, 1, 0, -1, 0],
up: [0, 0, 1],
forward: [0, -1, 0],
},
"x-up": {
worldAxis: [0, 1, 0, 1, 0, 0, 0, 0, -1],
up: [1, 0, 0],
forward: [0, 0, -1],
},
};
let templatesMenu = null;
let settingsMenu = null;
let measurementModal = null;
applyPanelOffsets();
let viewer = null;
let treeView = null;
let currentBlobUrl = null;
let modelCounter = 0;
const activeModels = [];
let viewerMode = "standard";
let runtimeSessionInfo = { authRequired: false, authenticated: false, canShare: true, user: null };
let startupSharePayload = null;
const memoryStore = new Map(); // url -> ArrayBuffer
const blobUrls = [];
let gizmo = null;
const transformStore = {}; // modelId -> objectId -> {position, rotation, scale}
const modelTypes = {}; // modelId -> normalized type
const sceneModels = {}; // modelId -> SceneModel
const pointCloudColorSnapshots = new Map(); // modelId -> [{ layer, colors, workingColors }]
let activeDisplayMode = "source";
let activeLightingMode = "ambient";
let activeNavigationAxis = "auto";
let activeZoomSpeed = 20;
let inspectorCollapsed = false;
let displayModeSelect = null;
let lightingModeSelect = null;
let navigationAxisSelect = null;
let zoomSpeedSlider = null;
let defaultCameraWorldAxis = null;
let defaultCameraUp = null;
let currentSelection = null; // { modelId, objectId }
const customTrees = new Map(); // modelId -> element
let navCubePlugin = null;
let measurePlugin = null;
let measureControl = null;
let measureActive = false;
let activeMeasurementId = null;
let sectionPlanesPlugin = null;
let activeSectionPlaneId = null;
let sectionPlaneCounter = 0;
let sectionModeActive = false;
let activeSectionAxis = null;
let objectContextModal = null;
let mathUtil = null;
let selectionState = null; // { ids, modelId, pivotCenter, baseTransforms: Map, transform }
let lastSelection = { ids: [], modelId: null };
let activeTransformTarget = null; // последняя цель для инпутов трансформаций
let sdkRef = null;
let projectIndex = { templates: [], projects: [] };
let currentProjectMeta = null;
let isLoadingSavedProject = false;
const uploadCache = new Map(); // srcKey -> uploaded src
let suppressEdgeReload = false;
let lastAttemptedProjectId = null;
const treeModelIds = new Set();
const CAMERA_CONTROL_SETTINGS = {
keyboardDollyRate: 45,
dollyInertia: 0,
dollyMinSpeed: 0.008,
dollyProximityThreshold: 90,
};
const normalizeZoomSpeed = (value) => {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 20;
return Math.min(100, Math.max(5, Math.round(numeric)));
};
const configureCameraPanMap = (control) => {
const input = viewer?.scene?.input;
if (!control || !input) return;
if (control.MOUSE_PAN === undefined || control.MOUSE_ROTATE === undefined || control.MOUSE_DOLLY === undefined) return;
control.panRightClick = true;
control.keyMap = {
...(control.keyMap || {}),
[control.MOUSE_PAN]: [
[input.MOUSE_LEFT_BUTTON, input.KEY_SHIFT],
input.MOUSE_MIDDLE_BUTTON,
input.MOUSE_RIGHT_BUTTON,
],
[control.MOUSE_ROTATE]: [input.MOUSE_LEFT_BUTTON],
[control.MOUSE_DOLLY]: [],
};
};
const configureCameraControl = () => {
const control = viewer?.cameraControl;
if (!control) return;
activeZoomSpeed = normalizeZoomSpeed(activeZoomSpeed);
configureCameraPanMap(control);
control.followPointer = true;
control.doublePickFlyTo = false;
control.smartPivot = true;
control.zoomOnMouseWheel = true;
control.keyboardDollyRate = CAMERA_CONTROL_SETTINGS.keyboardDollyRate;
control.mouseWheelDollyRate = activeZoomSpeed;
control.dollyInertia = CAMERA_CONTROL_SETTINGS.dollyInertia;
control.dollyMinSpeed = CAMERA_CONTROL_SETTINGS.dollyMinSpeed;
control.dollyProximityThreshold = CAMERA_CONTROL_SETTINGS.dollyProximityThreshold;
};
const setZoomSpeed = (value, { announce = false } = {}) => {
activeZoomSpeed = normalizeZoomSpeed(value);
zoomSpeedSlider?.setValue?.(activeZoomSpeed);
configureCameraControl();
if (announce) {
setStatus(`Скорость зума: ${activeZoomSpeed}%`);
}
};
const ensureCameraControlActive = () => {
if (!viewer || !viewer.cameraControl) return;
configureCameraControl();
if (viewer.cameraControl.enabled === false) {
viewer.cameraControl.enabled = true;
}
if (viewer.cameraControl.active === false) {
viewer.cameraControl.active = true;
}
};
const resetCameraControl = () => {
if (!viewer?.cameraControl) return;
// Toggle active to drop any stuck drag state without adding extra loops
viewer.cameraControl.active = false;
viewer.cameraControl.active = true;
};
const rearmCameraControl = () => {
ensureCameraControlActive();
resetCameraControl();
};
const focusCanvasAndControls = () => {
rearmCameraControl();
if (mainCanvas?.focus) {
mainCanvas.focus({ preventScroll: true });
}
};
const setToolbarButtonActive = (btn, active) => {
if (!btn) return;
btn.classList.toggle("active", !!active);
};
const updateProjectionButton = () => {
const mode = viewer?.camera?.projection || "perspective";
if (!projectionToggle) return;
if (mode === "ortho") {
projectionToggle.textContent = "P";
setToolbarButtonActive(projectionToggle, true);
} else {
projectionToggle.textContent = "A";
setToolbarButtonActive(projectionToggle, false);
}
};
const normalizeDisplayMode = (mode) => (
DISPLAY_MODE_OPTIONS.some((option) => option.value === mode) ? mode : "source"
);
const normalizeLightingMode = (mode) => (
LIGHTING_MODE_OPTIONS.some((option) => option.value === mode) ? mode : "ambient"
);
const normalizeNavigationAxis = (axis) => (
NAVIGATION_AXIS_OPTIONS.some((option) => option.value === axis) ? axis : "auto"
);
const setDisplayModeControlState = () => {
activeDisplayMode = normalizeDisplayMode(activeDisplayMode);
displayModeSelect?.setValue(activeDisplayMode);
};
const setLightingModeControlState = () => {
activeLightingMode = normalizeLightingMode(activeLightingMode);
lightingModeSelect?.setValue(activeLightingMode);
};
const setNavigationAxisControlState = () => {
activeNavigationAxis = normalizeNavigationAxis(activeNavigationAxis);
navigationAxisSelect?.setValue(activeNavigationAxis);
};
const normalizeModelSettingsSrc = (src) => {
if (!src || typeof src !== "string") return "";
let value = src.trim();
if (!value) return "";
try {
const parsed = new URL(value, window.location.href);
if (parsed.origin === window.location.origin || /^https?:/i.test(value)) {
value = parsed.pathname;
}
} catch (_) {
// keep raw value
}
value = value.replace(/^\/+/, "");
return value.startsWith("uploads/") ? value : "";
};
const getModelSettingsSrc = (entry) => {
const meta = entry?.meta || {};
const candidates = [
meta.settingsSrc,
meta.sourceSrc,
meta.originalSrc,
meta.conversion?.sourceSrc,
meta.manifest?.sourceSrc,
meta.artifactSrc,
entry?.src,
];
for (const candidate of candidates) {
const normalized = normalizeModelSettingsSrc(candidate);
if (normalized) return normalized;
}
return "";
};
const getPrimaryModelEntry = () => (
activeModels.find((entry) => entry?.model?.visible !== false) || activeModels[0] || null
);
const hasModelSettingsTarget = () => !!getModelSettingsSrc(getPrimaryModelEntry());
const isGuestMode = () => viewerMode === "guest";
const getShareTokenFromPath = () => {
const match = window.location.pathname.match(/^\/share\/([^/?#]+)/);
return match ? decodeURIComponent(match[1]) : null;
};
const applyRuntimeMode = () => {
document.body.dataset.viewerMode = viewerMode;
if (bottomToolbar) {
bottomToolbar.classList.toggle("hidden", isGuestMode());
}
if (shareButton) {
const canShowShare = !isGuestMode() && runtimeSessionInfo.canShare && activeModels.length > 0;
shareButton.classList.toggle("hidden", !canShowShare);
shareButton.disabled = !canShowShare;
}
if (dropZone) {
dropZone.style.pointerEvents = isGuestMode() ? "none" : "";
}
if (sectionToolbar && isGuestMode()) {
sectionToolbar.classList.add("hidden");
}
};
const initializeRuntimeMode = async () => {
const shareToken = getShareTokenFromPath();
if (shareToken) {
viewerMode = "guest";
try {
const response = await fetch(`${SHARES_API}/${encodeURIComponent(shareToken)}`, {
credentials: "include",
headers: { "Accept": "application/json" },
});
if (!response.ok) {
throw new Error(`Share link failed: HTTP ${response.status}`);
}
startupSharePayload = await response.json();
} catch (err) {
console.error(err);
showError("Ссылка на модель недоступна");
}
}
try {
const response = await fetch(AUTH_SESSION_API, {
credentials: "include",
headers: { "Accept": "application/json" },
});
if (response.ok) {
runtimeSessionInfo = await response.json();
}
} catch (err) {
console.warn("BIM session status unavailable", err);
}
applyRuntimeMode();
};
const getShareableModelPayload = () => {
const entry = getPrimaryModelEntry();
if (!entry) {
return null;
}
const src = normalizeModelSettingsSrc(entry.meta?.artifactSrc || entry.src || entry.meta?.src);
const settingsSrc = getModelSettingsSrc(entry);
if (!src) {
return null;
}
return {
assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || null,
name: entry.name || entry.meta?.label || src.split("/").pop() || "model",
settingsSrc: settingsSrc || src,
src,
type: entry.type || normalizeType(null, src),
versionId: entry.meta?.versionId || entry.meta?.conversion?.versionId || null,
};
};
const closeShareModal = () => {
shareModal?.classList.add("hidden");
shareModalBackdrop?.classList.add("hidden");
if (shareError) shareError.textContent = "";
};
const openShareModal = async () => {
if (isGuestMode()) return;
const payload = getShareableModelPayload();
if (!payload) {
showError("Ссылку можно создать только для модели из BIM-хранилища");
return;
}
shareModal?.classList.remove("hidden");
shareModalBackdrop?.classList.remove("hidden");
if (shareLinkInput) shareLinkInput.value = "Генерируем ссылку...";
if (shareCopyButton) shareCopyButton.disabled = true;
if (shareError) shareError.textContent = "";
try {
const response = await fetch(SHARES_API, {
method: "POST",
credentials: "include",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => null);
if (!response.ok || !data?.url) {
throw new Error(data?.error || `Share create failed: HTTP ${response.status}`);
}
if (shareLinkInput) {
shareLinkInput.value = data.url;
shareLinkInput.select();
}
if (shareCopyButton) shareCopyButton.disabled = false;
} catch (err) {
console.error(err);
if (shareLinkInput) shareLinkInput.value = "";
if (shareError) shareError.textContent = err?.message || "Не удалось создать ссылку";
}
};
const copyShareLink = async () => {
const value = shareLinkInput?.value || "";
if (!value) return;
try {
await navigator.clipboard?.writeText(value);
} catch (_) {
shareLinkInput?.select();
document.execCommand?.("copy");
}
};
const getDisplayModePreset = (mode) => {
switch (mode) {
case "clay":
return { colorize: [0.86, 0.86, 0.82], opacity: 1, edges: false, xrayed: false };
case "white":
return { colorize: [1, 1, 1], opacity: 1, edges: false, xrayed: false };
case "contrast":
return { colorize: [0.78, 0.82, 1], opacity: 1, edges: false, xrayed: false };
case "edges":
return { colorize: null, opacity: 1, edges: true, xrayed: false };
case "xray":
return { colorize: [0.72, 0.9, 1], opacity: 1, edges: true, xrayed: true };
case "source":
default:
return { colorize: null, opacity: 1, edges: "base", xrayed: false };
}
};
const getBaseEdgesForModel = (modelId) => {
const modelType = modelTypes[modelId];
if (modelType === "las") return false;
if (modelType === "gltf") return false;
return !!toggleEdges?.checked;
};
const isPointCloudModel = (modelId) => modelTypes[modelId] === "las";
const getPointCloudLayers = (model) => (
(model?.layerList || []).filter((layer) => (
layer?.primitive === "points" &&
layer?._state?.colorsBuf?.getData &&
layer?._state?.colorsBuf?.setData
))
);
const snapshotPointCloudColors = (modelId) => {
const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId];
if (!model || !isPointCloudModel(modelId) || pointCloudColorSnapshots.has(modelId)) return;
const snapshots = getPointCloudLayers(model)
.map((layer) => {
try {
const colors = layer._state.colorsBuf.getData();
return colors?.length ? { layer, colors, workingColors: null } : null;
} catch (err) {
console.warn("point cloud color snapshot failed", err);
return null;
}
})
.filter(Boolean);
if (snapshots.length) {
pointCloudColorSnapshots.set(modelId, snapshots);
}
};
const clearPointCloudEntityColorState = (targets) => {
targets.forEach((entity) => {
if (!entity) return;
try {
entity.xrayed = false;
entity.opacity = 1;
entity.edges = false;
(entity.meshes || []).forEach((mesh) => {
mesh._colorizing = false;
});
if (entity._colorizeUpdated && viewer?.scene?._objectColorizeUpdated) {
viewer.scene._objectColorizeUpdated(entity, false);
entity._colorizeUpdated = false;
}
} catch (err) {
console.warn("point cloud entity reset failed", err);
}
});
};
const writePointCloudColors = (modelId, colorize = null) => {
snapshotPointCloudColors(modelId);
const snapshots = pointCloudColorSnapshots.get(modelId) || [];
const color = colorize
? colorize.map((v) => Math.max(0, Math.min(255, Math.round(v * 255))))
: null;
snapshots.forEach((snapshot) => {
const colorsBuf = snapshot.layer?._state?.colorsBuf;
if (!colorsBuf?.setData) return;
try {
if (!color) {
colorsBuf.setData(snapshot.colors);
return;
}
const original = snapshot.colors;
const working = snapshot.workingColors?.length === original.length
? snapshot.workingColors
: new Uint8Array(original.length);
for (let i = 0; i < original.length; i += 4) {
working[i] = color[0];
working[i + 1] = color[1];
working[i + 2] = color[2];
working[i + 3] = original[i + 3];
}
snapshot.workingColors = working;
colorsBuf.setData(working);
} catch (err) {
console.warn("point cloud color restore failed", err);
}
});
};
const applyPointCloudDisplayMode = (modelId, preset, targets) => {
clearPointCloudEntityColorState(targets);
writePointCloudColors(modelId, preset.colorize);
viewer?.scene?.glRedraw?.();
};
const applyDisplayModeToModel = (modelId) => {
const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId];
if (!model) return;
const preset = getDisplayModePreset(normalizeDisplayMode(activeDisplayMode));
const objects = Object.values(model.objects || {});
const targets = objects.length ? objects : [model];
if (isPointCloudModel(modelId)) {
applyPointCloudDisplayMode(modelId, preset, targets);
return;
}
targets.forEach((entity) => {
if (!entity) return;
try {
entity.xrayed = !!preset.xrayed;
entity.colorize = preset.colorize;
entity.opacity = preset.opacity;
entity.edges = modelTypes[modelId] === "las"
? false
: (preset.edges === "base" ? getBaseEdgesForModel(modelId) : !!preset.edges);
} catch (err) {
console.warn("display mode update failed", err);
}
});
viewer?.scene?.glRedraw?.();
};
const applyDisplayMode = () => {
setDisplayModeControlState();
Object.keys(sceneModels).forEach((modelId) => applyDisplayModeToModel(modelId));
};
const createSceneLight = (type, cfg) => {
if (!viewer?.scene || !sdkRef) return null;
if (type === "ambient" && sdkRef.AmbientLight) {
return new sdkRef.AmbientLight(viewer.scene, cfg);
}
if (type === "dir" && sdkRef.DirLight) {
return new sdkRef.DirLight(viewer.scene, cfg);
}
return null;
};
const resetSceneLighting = () => {
if (!viewer?.scene) return;
viewer.scene.clearLights?.();
};
const applyLightingMode = ({ announce = false } = {}) => {
activeLightingMode = normalizeLightingMode(activeLightingMode);
setLightingModeControlState();
if (!viewer?.scene || !sdkRef) return;
const scene = viewer.scene;
resetSceneLighting();
scene.pbrEnabled = false;
scene.colorTextureEnabled = true;
if (scene.sao) {
scene.sao.enabled = false;
}
switch (activeLightingMode) {
case "standard":
createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.7 });
createSceneLight("dir", {
dir: [0.8, -0.5, -0.5],
color: [0.67, 0.67, 1.0],
intensity: 0.7,
space: "world",
});
createSceneLight("dir", {
dir: [-0.8, -1.0, 0.5],
color: [1.0, 1.0, 0.9],
intensity: 0.9,
space: "world",
});
break;
case "soft":
createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.82 });
createSceneLight("dir", {
dir: [0.7, -0.65, -0.55],
color: [1.0, 1.0, 1.0],
intensity: 0.22,
space: "view",
});
createSceneLight("dir", {
dir: [-0.6, -0.35, 0.7],
color: [0.82, 0.88, 1.0],
intensity: 0.16,
space: "view",
});
break;
case "technical":
createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.62 });
createSceneLight("dir", {
dir: [0.9, -0.45, -0.35],
color: [0.95, 0.98, 1.0],
intensity: 0.34,
space: "view",
});
createSceneLight("dir", {
dir: [-0.55, -0.75, 0.45],
color: [1.0, 0.96, 0.88],
intensity: 0.24,
space: "view",
});
if (scene.sao) {
scene.sao.enabled = true;
scene.sao.intensity = 0.08;
scene.sao.bias = 0.7;
scene.sao.kernelRadius = 48;
scene.sao.numSamples = 20;
}
break;
case "ambient":
default:
createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.92 });
break;
}
scene.glRedraw?.();
if (announce) {
const label = LIGHTING_MODE_OPTIONS.find((option) => option.value === activeLightingMode)?.label || "Свет";
setStatus(`Свет: ${label}`);
}
};
const cloneVec3 = (vec, fallback = [0, 0, 0]) => (
Array.isArray(vec) || ArrayBuffer.isView(vec) ? Array.from(vec).slice(0, 3) : [...fallback]
);
const normalizeVec3 = (vec, fallback = [0, 0, 1]) => {
const [x, y, z] = cloneVec3(vec, fallback);
const length = Math.hypot(x, y, z);
return length > 0.00001 ? [x / length, y / length, z / length] : [...fallback];
};
const dotVec3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const getNavigationAxisPreset = (axis) => {
if (axis === "auto") {
const worldAxis = defaultCameraWorldAxis || NAVIGATION_AXIS_PRESETS["y-up"].worldAxis;
return {
worldAxis,
up: defaultCameraUp || NAVIGATION_AXIS_PRESETS["y-up"].up,
forward: worldAxis.length >= 9 ? worldAxis.slice(6, 9) : NAVIGATION_AXIS_PRESETS["y-up"].forward,
};
}
return NAVIGATION_AXIS_PRESETS[axis] || NAVIGATION_AXIS_PRESETS["y-up"];
};
const applyNavigationAxis = ({ resetAuto = true, announce = false } = {}) => {
activeNavigationAxis = normalizeNavigationAxis(activeNavigationAxis);
setNavigationAxisControlState();
if (!viewer?.camera) return;
if (activeNavigationAxis === "auto" && !resetAuto) return;
const preset = getNavigationAxisPreset(activeNavigationAxis);
const camera = viewer.camera;
const up = normalizeVec3(preset.up, [0, 1, 0]);
const eye = cloneVec3(camera.eye, [0, 0, 10]);
const look = cloneVec3(camera.look, [0, 0, 0]);
const eyeVector = [eye[0] - look[0], eye[1] - look[1], eye[2] - look[2]];
const viewDirection = normalizeVec3(eyeVector, preset.forward);
const distance = Math.hypot(eyeVector[0], eyeVector[1], eyeVector[2]) || 10;
camera.worldAxis = [...preset.worldAxis];
camera.gimbalLock = true;
if (Math.abs(dotVec3(viewDirection, up)) > 0.96) {
const forward = normalizeVec3(preset.forward, [0, 0, 1]);
camera.eye = [
look[0] + forward[0] * distance,
look[1] + forward[1] * distance,
look[2] + forward[2] * distance,
];
}
camera.up = [...up];
viewer.scene?.glRedraw?.();
rearmCameraControl();
if (announce) {
const label = NAVIGATION_AXIS_OPTIONS.find((option) => option.value === activeNavigationAxis)?.label || "Авто";
setStatus(`Ось навигации: ${label}`);
}
};
const toggleProjection = () => {
if (!viewer?.camera) return;
const cam = viewer.camera;
const next = cam.projection === "ortho" ? "perspective" : "ortho";
const eye = cam.eye ? [...cam.eye] : [0, 0, 10];
const look = cam.look ? [...cam.look] : [0, 0, 0];
const dx = eye[0] - look[0];
const dy = eye[1] - look[1];
const dz = eye[2] - look[2];
const dist = Math.hypot(dx, dy, dz) || 1;
const dir = dist > 0 ? [dx / dist, dy / dist, dz / dist] : [0, 0, 1];
const fovDeg = cam.perspective?.fov || 60;
const fovRad = (fovDeg * Math.PI) / 180;
const boundary = viewer.scene?.canvas?.boundary;
const canvas = viewer.scene?.canvas?.canvas;
const width = (boundary && boundary[2]) || canvas?.offsetWidth || canvas?.clientWidth || 1;
const height = (boundary && boundary[3]) || canvas?.offsetHeight || canvas?.clientHeight || 1;
const aspect = width > 0 && height > 0 ? width / height : 1;
if (next === "ortho") {
const perspHeight = 2 * dist * Math.tan(fovRad / 2);
const perspWidth = perspHeight * aspect;
const scale = aspect >= 1 ? perspWidth : perspHeight;
if (cam.ortho) cam.ortho.scale = scale;
cam.projection = "ortho";
} else {
const scale = cam.ortho?.scale || 1;
const orthoHeight = aspect >= 1 ? scale / aspect : scale;
const targetDist = (orthoHeight / 2) / Math.tan(fovRad / 2);
cam.eye = [look[0] + dir[0] * targetDist, look[1] + dir[1] * targetDist, look[2] + dir[2] * targetDist];
cam.projection = "perspective";
}
updateProjectionButton();
setStatus(next === "ortho" ? "Аксонометрия" : "Перспектива");
};
const closeSettingsPanel = () => {
settingsMenu?.hide();
setToolbarButtonActive(settingsButton, false);
};
const closeTemplatesPanel = () => {
templatesMenu?.hide();
setToolbarButtonActive(templatesButton, false);
};
const openSettingsPanel = () => {
closeTemplatesPanel();
settingsMenu?.show();
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 clampNumber = (value, min, max, fallback) => {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(min, Math.min(max, num));
};
const hexToRgb01 = (hex) => {
if (!hex) return [1, 1, 1];
const clean = hex.trim();
if (clean.startsWith("rgb")) {
const parts = clean.replace(/rgba?\(/, "").replace(")", "").split(",").map((p) => parseFloat(p.trim()) || 0);
return [(parts[0] || 0) / 255, (parts[1] || 0) / 255, (parts[2] || 0) / 255];
}
const h = clean.replace("#", "");
const norm = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const num = parseInt(norm, 16);
if (Number.isNaN(num)) return [1, 1, 1];
return [(num >> 16 & 255) / 255, (num >> 8 & 255) / 255, (num & 255) / 255];
};
const measureThemeFromCSS = () => {
const pick = (name, fallback) => {
const v = cssValue(name);
return v || fallback;
};
return {
line: pick("--measure-line", "#00bbff"),
label: pick("--measure-label", "#1f2a3c"),
labelText: pick("--measure-label-text", "#ffffff"),
dot: pick("--measure-dot", "#00bbff"),
};
};
const applyMeasureTheme = () => {
if (!measurePlugin) return;
const theme = measureThemeFromCSS();
measurePlugin.defaultColor = theme.line;
const applyToMeasurement = (m) => {
if (!m) return;
m.color = theme.line;
m._lengthLabel?.setFillColor?.(theme.label);
m._originDot?.setFillColor?.(theme.dot);
m._originDot?.setBorderColor?.(theme.line);
m._targetDot?.setFillColor?.(theme.dot);
m._targetDot?.setBorderColor?.(theme.line);
};
Object.values(measurePlugin.measurements || {}).forEach(applyToMeasurement);
document.querySelectorAll(".viewer-ruler-label").forEach((el) => {
el.style.color = theme.labelText;
el.style.background = theme.label;
el.style.borderColor = theme.line;
});
document.querySelectorAll(".viewer-ruler-dot").forEach((el) => {
el.style.background = theme.dot;
el.style.borderColor = theme.line;
});
document.querySelectorAll(".viewer-ruler-dot-clickable").forEach((el) => {
el.style.borderColor = theme.line;
});
};
const sectionPlaneThemeFromCSS = () => ({
axes: {
x: hexToRgb01(cssValue("--section-axis-x", "#ff2b2b")),
y: hexToRgb01(cssValue("--section-axis-y", "#35ff4f")),
z: hexToRgb01(cssValue("--section-axis-z", "#285bff")),
},
gizmoDiameter: clampNumber(parseFloat(cssValue("--section-gizmo-diameter", "1")), 0.5, 1.8, 1),
planeFillColor: hexToRgb01(cssValue("--section-plane-fill", "#606060")),
planeFillAlpha: clampNumber(parseFloat(cssValue("--section-plane-alpha", "0.32")), 0, 1, 0.32),
planeEdgeColor: hexToRgb01(cssValue("--section-plane-edge", "#0d0d0d")),
planeEdgeAlpha: clampNumber(parseFloat(cssValue("--section-plane-edge-alpha", "0.8")), 0, 1, 0.8),
});
const applySectionPlaneTheme = () => {
if (!sectionPlanesPlugin) return;
const theme = sectionPlaneThemeFromCSS();
Object.values(sectionPlanesPlugin._controls || {}).forEach((control) => {
control?.setStyle?.(theme);
});
Object.values(sectionPlanesPlugin._overview?._planes || {}).forEach((plane) => {
const mesh = plane?._mesh;
if (!mesh) return;
if (mesh.material) {
mesh.material.diffuse = theme.planeFillColor;
mesh.material.emissive = theme.planeFillColor;
mesh.material.alpha = theme.planeFillAlpha;
mesh.material.alphaMode = theme.planeFillAlpha < 1 ? "blend" : "opaque";
}
if (mesh.edgeMaterial) {
mesh.edgeMaterial.edgeColor = theme.planeEdgeColor;
mesh.edgeMaterial.edgeAlpha = theme.planeEdgeAlpha;
}
if (mesh.highlightMaterial) {
mesh.highlightMaterial.fillColor = theme.planeFillColor;
mesh.highlightMaterial.fillAlpha = theme.planeFillAlpha;
mesh.highlightMaterial.edgeColor = theme.planeEdgeColor;
mesh.highlightMaterial.edgeAlpha = theme.planeEdgeAlpha;
}
if (mesh.selectedMaterial) {
mesh.selectedMaterial.fillColor = theme.planeFillColor;
mesh.selectedMaterial.fillAlpha = theme.planeFillAlpha;
mesh.selectedMaterial.edgeColor = theme.planeEdgeColor;
mesh.selectedMaterial.edgeAlpha = theme.planeEdgeAlpha;
}
mesh.opacity = theme.planeFillAlpha;
});
viewer?.scene?.glRedraw?.();
sectionPlanesPlugin._overview?._scene?.glRedraw?.();
};
const applyHighlightTheme = () => {
if (!viewer?.scene?.highlightMaterial) return;
const hexToRGB = (hex) => {
const h = hex.replace("#", "");
const num = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);
if (Number.isNaN(num)) return [0.72, 0.2, 1];
return [(num >> 16 & 255) / 255, (num >> 8 & 255) / 255, (num & 255) / 255];
};
const hl = cssValue("--highlight-color", "#b833ff");
const color = hexToRGB(hl);
viewer.scene.highlightMaterial.color = color;
if (viewer.scene.highlightMaterial.edgeColor) {
viewer.scene.highlightMaterial.edgeColor = color;
}
if (viewer.scene.highlightMaterial.fillColor) {
viewer.scene.highlightMaterial.fillColor = [...color, 0.5];
}
viewer.scene.glRedraw();
};
const normalizeType = (type, src = "") => {
const lower = (type || "").toLowerCase();
if (lower === "glb" || lower === "gltf") return "gltf";
if (lower === "laz" || lower === "las" || lower === "pointcloud" || lower === "point_cloud") return "las";
if (lower === "xkt") return "xkt";
if (lower === "bim") return "bim";
if (lower === "obj") return "obj";
if (lower === "stl") return "stl";
if (src) {
return normalizeType(guessTypeFromName(src));
}
return lower || null;
};
const log = (text, isError = false) => {
if (!logList) return;
const entry = document.createElement("div");
entry.textContent = text;
entry.style.color = isError ? "var(--error)" : "var(--muted)";
logList.appendChild(entry);
while (logList.children.length > 20) {
logList.removeChild(logList.firstChild);
}
logList.scrollTop = logList.scrollHeight;
if (isError) console.error(text);
};
const reportViewerEvent = (event, data = {}, level = "info") => {
const payload = {
event,
level,
data,
href: window.location.href,
ts: new Date().toISOString(),
};
const logger = level === "error" ? console.error : console.log;
logger(`[beam-viewer] ${event}`, data);
fetch("/api/client-log", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {});
};
window.addEventListener("error", (event) => {
reportViewerEvent("window-error", {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
}, "error");
});
window.addEventListener("unhandledrejection", (event) => {
reportViewerEvent("window-unhandled-rejection", {
reason: event.reason?.message || String(event.reason),
stack: event.reason?.stack,
}, "error");
});
const setStatus = (text) => {
statusText.textContent = text;
statusText.style.color = "";
log(text, false);
};
const showError = (text) => {
statusText.textContent = text;
statusText.style.color = "var(--error)";
setTimeout(() => {
statusText.style.color = "";
}, 2000);
log(text, true);
};
const setLoading = (flag) => {
if (!loaderEl) return;
if (flag) {
loaderEl.classList.remove("hidden");
} else {
loaderEl.classList.add("hidden");
}
};
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const probeWebGLContext = (canvas, contextAttr = {}) => {
if (!canvas) return null;
const contextNames = ["webgl2", "experimental-webgl", "webgl"];
for (const contextName of contextNames) {
try {
const gl = canvas.getContext(contextName, contextAttr);
if (gl) return { contextName, gl };
} catch (_error) {
// Try next context type.
}
}
return null;
};
const waitForViewerCanvasReady = async (contextAttr = {}) => {
for (let attempt = 1; attempt <= 30; attempt++) {
const rect = mainCanvas?.getBoundingClientRect?.();
const hasSize = !!rect && rect.width >= 32 && rect.height >= 32;
const webglProbe = hasSize ? probeWebGLContext(mainCanvas, contextAttr) : null;
if (hasSize && webglProbe?.gl) {
reportViewerEvent("webgl-ready", {
attempt,
contextName: webglProbe.contextName,
height: Math.round(rect.height),
width: Math.round(rect.width),
});
return;
}
if (attempt === 1 || attempt % 10 === 0) {
reportViewerEvent("webgl-wait", {
attempt,
hasCanvas: !!mainCanvas,
height: rect ? Math.round(rect.height) : 0,
hasSize,
width: rect ? Math.round(rect.width) : 0,
webgl: !!webglProbe?.gl,
});
}
await delay(150);
}
throw new Error("WebGL-контекст недоступен: браузер не выдал canvas context для BIM Viewer");
};
const updateNavCubeVisibility = () => {
if (!navCubeCanvas) return;
navCubeCanvas.style.display = activeModels.length ? "block" : "none";
};
const setMeasureActive = (active) => {
const isActive = !!active && !!measurePlugin && !!measureControl;
measureActive = isActive;
if (measureControl) {
if (isActive) {
measureControl.activate();
} else {
measureControl.deactivate();
}
}
if (measureButton) {
measureButton.classList.toggle("active", isActive);
}
if (!isActive) {
closeMeasurementModal();
}
};
const closeMeasurementModal = () => {
measurementModal?.hide?.();
activeMeasurementId = null;
};
const clearMeasurements = () => {
measurePlugin?.clear?.();
closeMeasurementModal();
};
const getSectionPlaneIds = () => Object.keys(sectionPlanesPlugin?.sectionPlanes || {});
const getActiveSectionPlane = () => {
if (!sectionPlanesPlugin) return null;
if (activeSectionPlaneId && sectionPlanesPlugin.sectionPlanes?.[activeSectionPlaneId]) {
return sectionPlanesPlugin.sectionPlanes[activeSectionPlaneId];
}
const firstId = getSectionPlaneIds()[0] || null;
activeSectionPlaneId = firstId;
return firstId ? sectionPlanesPlugin.sectionPlanes[firstId] : null;
};
const setAllSectionPlanesActive = (active) => {
getSectionPlaneIds().forEach((id) => {
const sectionPlane = sectionPlanesPlugin?.sectionPlanes?.[id];
if (sectionPlane) {
sectionPlane.active = !!active;
}
});
};
const getSectionCenter = () => {
const sceneAABB = viewer?.scene?.aabb;
const aabb = Array.isArray(sceneAABB) || ArrayBuffer.isView(sceneAABB)
? Array.from(sceneAABB)
: null;
const center = centerFromAABB(aabb);
return center.every((value) => Number.isFinite(value)) ? center : [0, 0, 0];
};
const getCameraSectionDir = () => {
const eye = snapshotVec(viewer?.camera?.eye) || [0, 0, 1];
const look = snapshotVec(viewer?.camera?.look) || [0, 0, 0];
return normalizeVec3([
look[0] - eye[0],
look[1] - eye[1],
look[2] - eye[2],
], [0, 0, -1]);
};
const setSectionAxisActive = (axis) => {
activeSectionAxis = axis || null;
sectionToolbar?.querySelectorAll("[data-section-action]").forEach((btn) => {
btn.classList.toggle("active", !!axis && btn.dataset.sectionAction === axis);
});
};
const positionSectionToolbar = () => {
if (!sectionButton || !sectionToolbar) return;
const rect = sectionButton.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const gap = parseFloat(cssValue("--tb-gap", "10")) || 10;
const root = document.documentElement;
root.style.setProperty("--section-toolbar-left", `${Math.round(rect.right + gap)}px`);
root.style.setProperty("--section-toolbar-bottom", `${Math.round(Math.max(8, window.innerHeight - rect.bottom))}px`);
};
const setSectionModeActive = (active) => {
sectionModeActive = !!active && !!sectionPlanesPlugin && getSectionPlaneIds().length > 0 && !isGuestMode();
if (sectionModeActive) {
positionSectionToolbar();
}
sectionButton?.classList.toggle("active", sectionModeActive);
sectionToolbar?.classList.toggle("hidden", !sectionModeActive);
document.body.dataset.sectionMode = sectionModeActive ? "active" : "";
sectionPlanesPlugin?.setOverviewVisible?.(sectionModeActive);
if (!sectionModeActive) {
setSectionAxisActive(null);
sectionPlanesPlugin?.hideControl?.();
}
};
const updateSectionControls = () => {
const available = !!sectionPlanesPlugin && !isGuestMode() && activeModels.length > 0;
if (sectionButton) {
sectionButton.disabled = !available;
sectionButton.classList.toggle("disabled", !available);
sectionButton.classList.toggle("active", available && sectionModeActive);
}
if (!available) {
sectionToolbar?.classList.add("hidden");
document.body.dataset.sectionMode = "";
sectionPlanesPlugin?.setOverviewVisible?.(false);
}
};
const createOrUpdateSectionPlane = (axis = "camera") => {
if (!sectionPlanesPlugin) {
showError("Разрез недоступен в текущей сборке SDK");
return null;
}
if (!activeModels.length) {
showError("Сначала загрузите модель");
return null;
}
setMeasureActive(false);
const dirs = {
camera: getCameraSectionDir(),
x: [1, 0, 0],
y: [0, 1, 0],
z: [0, 0, 1],
};
const dir = dirs[axis] || dirs.camera;
const pos = getSectionCenter();
let sectionPlane = getActiveSectionPlane();
if (!sectionPlane) {
const id = `sectionPlane-${++sectionPlaneCounter}`;
sectionPlane = sectionPlanesPlugin.createSectionPlane({ id, pos, dir });
activeSectionPlaneId = sectionPlane.id || id;
} else {
sectionPlane.pos = pos;
sectionPlane.dir = dir;
sectionPlane.active = true;
activeSectionPlaneId = sectionPlane.id || activeSectionPlaneId;
}
applySectionPlaneTheme();
sectionPlanesPlugin.showControl(activeSectionPlaneId);
setSectionModeActive(true);
setSectionAxisActive(axis);
setStatus("Разрез включён");
rearmCameraControl();
return sectionPlane;
};
const clearSectionPlanes = () => {
sectionPlanesPlugin?.clear?.();
activeSectionPlaneId = null;
setSectionModeActive(false);
};
const flipSectionPlanes = () => {
if (!getActiveSectionPlane()) {
createOrUpdateSectionPlane("camera");
return;
}
setAllSectionPlanesActive(true);
sectionPlanesPlugin?.flipSectionPlanes?.();
sectionPlanesPlugin?.showControl?.(activeSectionPlaneId);
setSectionModeActive(true);
setStatus("Разрез инвертирован");
};
const toggleSectionPlanes = () => {
if (sectionModeActive) {
setAllSectionPlanesActive(false);
setSectionModeActive(false);
setStatus("Разрез выключен");
return;
}
if (getSectionPlaneIds().length > 0) {
setAllSectionPlanesActive(true);
activeSectionPlaneId = activeSectionPlaneId || getSectionPlaneIds()[0];
sectionPlanesPlugin?.showControl?.(activeSectionPlaneId);
setSectionModeActive(true);
setStatus("Разрез включён");
return;
}
createOrUpdateSectionPlane("camera");
};
const rebuildNavCube = (sdk) => {
if (!navCubeCanvas || !viewer) return;
const cubeColor = cssValue("--cube-base", "#7b3fff");
const cubeHover = cssValue("--cube-hover", "rgba(255,255,255,0.4)");
const cubeText = cssValue("--cube-text", "#ffffff");
const edgeColorHex = cssValue("--cube-edge-color", "#e0e7ff");
const edgeAlpha = parseFloat(cssValue("--cube-edge-alpha", "0.6")) || 0.6;
if (!navCubePlugin) {
navCubePlugin = new sdk.NavCubePlugin(viewer, {
canvasId: "navCube",
size: 100,
visible: true,
color: cubeColor,
textColor: cubeText,
hoverColor: cubeHover,
});
}
// update textures/colors on existing plugin without recreating WebGL context
const navScene = navCubePlugin?._navCubeScene;
const cubeTextureCtor = navCubePlugin?._cubeTextureCanvas?.constructor;
if (cubeTextureCtor && navScene) {
navCubePlugin._cubeTextureCanvas?.destroy?.();
const ctc = new cubeTextureCtor(viewer, navScene, {
color: cubeColor,
hoverColor: cubeHover,
textColor: cubeText,
});
ctc.clear?.();
navCubePlugin._cubeTextureCanvas = ctc;
const image = ctc.getImage();
if (navCubePlugin._cubeSampler) {
navCubePlugin._cubeSampler.image = image;
navCubePlugin._cubeSampler._imageDirty = true;
navCubePlugin._cubeSampler.glTexture = null;
}
if (navCubePlugin._cubeMesh?.material) {
navCubePlugin._cubeMesh.material.diffuseMap.image = image;
navCubePlugin._cubeMesh.material.diffuseMap._imageDirty = true;
navCubePlugin._cubeMesh.material.diffuseMap.glTexture = null;
navCubePlugin._cubeMesh.material.emissiveMap.image = image;
navCubePlugin._cubeMesh.material.emissiveMap._imageDirty = true;
navCubePlugin._cubeMesh.material.emissiveMap.glTexture = null;
}
navCubePlugin._repaint?.();
}
if (navScene?.edgeMaterial) {
navScene.edgeMaterial.edgeColor = hexToRgb01(edgeColorHex);
navScene.edgeMaterial.edgeAlpha = edgeAlpha;
navScene.glRedraw();
}
updateNavCubeVisibility();
};
const updatePanelsVisibility = () => {
const hasModels = activeModels.length > 0;
if (sidePanelEl) {
sidePanelEl.classList.toggle("is-collapsed", hasModels && inspectorCollapsed);
sidePanelEl.style.display = hasModels && !inspectorCollapsed ? "flex" : "none";
}
if (inspectorRestoreTray) {
inspectorRestoreTray.hidden = !hasModels || !inspectorCollapsed;
}
if (treeContainer) treeContainer.classList.toggle("hidden", !hasModels);
const showActions = hasModels || !!currentProjectMeta || !!lastAttemptedProjectId;
if (saveProjectSection) {
saveProjectSection.classList.toggle("hidden", isGuestMode() || !showActions);
}
if (projectNameSection) {
projectNameSection.classList.toggle("hidden", isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId));
}
if (saveProjectButton) {
saveProjectButton.disabled = isGuestMode() || !hasModels;
}
if (saveModelSettingsButton) {
saveModelSettingsButton.disabled = isGuestMode() || !hasModels || !hasModelSettingsTarget();
}
if (deleteProjectButton) {
deleteProjectButton.disabled = isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId);
}
if (projectNameInput) {
const hasProject = !isGuestMode() && !!(currentProjectMeta || lastAttemptedProjectId);
projectNameInput.disabled = !hasProject;
if (!hasProject) {
projectNameInput.value = "";
}
}
updateSectionControls();
applyRuntimeMode();
};
const setInspectorCollapsed = (collapsed) => {
inspectorCollapsed = !!collapsed;
if (inspectorCollapseButton) {
inspectorCollapseButton.setAttribute("aria-expanded", String(!inspectorCollapsed));
}
updatePanelsVisibility();
focusCanvasAndControls();
};
const centerFromAABB = (aabb) => {
if (!Array.isArray(aabb) || aabb.length !== 6) return [0, 0, 0];
return [
(aabb[0] + aabb[3]) / 2,
(aabb[1] + aabb[4]) / 2,
(aabb[2] + aabb[5]) / 2,
];
};
const updateGlobalInputs = (pos) => {
if (!globalInputs.x || !globalInputs.y || !globalInputs.z) return;
const safe = pos && pos.length === 3 ? pos : ["", "", ""];
globalInputs.x.value = safe[0];
globalInputs.y.value = safe[1];
globalInputs.z.value = safe[2];
};
const getAABBForSelection = (ids, modelId = null) => {
if (!viewer || !viewer.scene) return null;
const scene = viewer.scene;
if (ids && ids.length) {
return Array.from(scene.getAABB(ids) || []);
}
if (modelId && scene.models?.[modelId]) {
const model = scene.models[modelId];
const idsFromModel = Object.keys(model.objects || {});
return Array.from(scene.getAABB(idsFromModel) || model.aabb || []);
}
return null;
};
const applyGlobalTarget = () => {
if (!viewer || !viewer.scene) return;
const scene = viewer.scene;
const target = [
parseFloat(globalInputs.x?.value),
parseFloat(globalInputs.y?.value),
parseFloat(globalInputs.z?.value),
];
try {
if (!target.every((v) => Number.isFinite(v))) {
console.warn("[GlobalCoords] invalid target", target);
return;
}
const ids = lastSelection.ids || [];
const modelId = lastSelection.modelId;
const currentAABB = getAABBForSelection(ids, modelId);
if (!currentAABB || currentAABB.length !== 6) {
console.warn("[GlobalCoords] no AABB for selection");
return;
}
const currentCenter = centerFromAABB(currentAABB);
const delta = [
target[0] - currentCenter[0],
target[1] - currentCenter[1],
target[2] - currentCenter[2],
];
// если есть модель — сдвигаем модель
if (modelId && scene.models?.[modelId]) {
const model = scene.models[modelId];
const basePos = model.position || [0, 0, 0];
model.position = [
(basePos[0] || 0) + delta[0],
(basePos[1] || 0) + delta[1],
(basePos[2] || 0) + delta[2],
];
// persist transform
persistTransform(modelId, "__model__", {
position: [...model.position],
rotation: model.rotation ? [...model.rotation] : [0, 0, 0],
scale: model.scale ? [...model.scale] : [1, 1, 1],
});
} else if (ids.length) {
// иначе сдвигаем объекты по отдельности
ids.forEach((id) => {
const ent = scene.objects?.[id];
if (!ent) return;
const basePos = ent.position || [0, 0, 0];
ent.position = [
(basePos[0] || 0) + delta[0],
(basePos[1] || 0) + delta[1],
(basePos[2] || 0) + delta[2],
];
if (modelId) {
persistTransform(modelId, id, {
position: [...ent.position],
rotation: ent.rotation ? [...ent.rotation] : [0, 0, 0],
scale: ent.scale ? [...ent.scale] : [1, 1, 1],
});
}
});
} else {
console.warn("[GlobalCoords] nothing selected");
return;
}
// обновляем отображение и состояние
const newAABB = getAABBForSelection(ids, modelId);
updateGlobalInputs(centerFromAABB(newAABB || []));
scene.setAABBDirty?.();
scene.glRedraw?.();
console.log("[GlobalCoords] applied delta", { delta, target, modelId, count: ids.length });
} finally {
ensureCameraControlActive();
resetCameraControl();
if (viewer?.cameraControl) {
viewer.cameraControl.enabled = true;
viewer.cameraControl.active = true;
}
focusCanvasAndControls();
}
};
["x", "y", "z"].forEach((key) => {
const el = globalInputs[key];
if (!el) return;
el.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
applyGlobalTarget();
}
});
el.addEventListener("blur", () => {
applyGlobalTarget();
focusCanvasAndControls();
});
});
const scheduleCameraRearm = () => {
rearmCameraControl();
window.setTimeout(rearmCameraControl, 60);
window.setTimeout(rearmCameraControl, 220);
};
window.addEventListener("blur", scheduleCameraRearm);
window.addEventListener("focus", scheduleCameraRearm);
window.addEventListener("pageshow", scheduleCameraRearm);
window.addEventListener("pointerup", scheduleCameraRearm, true);
window.addEventListener("mouseup", scheduleCameraRearm, true);
window.addEventListener("touchend", scheduleCameraRearm, true);
window.addEventListener("touchcancel", scheduleCameraRearm, true);
window.addEventListener("resize", () => {
if (sectionModeActive) {
positionSectionToolbar();
}
});
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
scheduleCameraRearm();
}
});
const makeTranslationMat = (x, y, z) => {
const m = mathUtil?.identityMat4 ? mathUtil.identityMat4() : [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
if (m.length === 16) {
m[12] = x;
m[13] = y;
m[14] = z;
}
return m;
};
const degToRad = (deg) => (deg * Math.PI) / 180;
const rotateVec3 = (v, rotDeg) => {
const [x, y, z] = v;
const rx = degToRad(rotDeg[0] || 0);
const ry = degToRad(rotDeg[1] || 0);
const rz = degToRad(rotDeg[2] || 0);
// X
let cy = Math.cos(rx), sy = Math.sin(rx);
let v1y = y * cy - z * sy;
let v1z = y * sy + z * cy;
let v1x = x;
// Y
let cz = Math.cos(ry), sz = Math.sin(ry);
let v2x = v1x * cz + v1z * sz;
let v2z = -v1x * sz + v1z * cz;
let v2y = v1y;
// Z
let cx = Math.cos(rz), sx = Math.sin(rz);
let v3x = v2x * cx - v2y * sx;
let v3y = v2x * sx + v2y * cx;
let v3z = v2z;
return [v3x, v3y, v3z];
};
const applyGroupTransform = (transform) => {
if (!selectionState || !viewer) return;
const scene = viewer.scene;
const current = selectionState.transform || {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
};
const pos = transform?.position || current.position || [0, 0, 0];
const rot = transform?.rotation || current.rotation || [0, 0, 0];
const scl = transform?.scale || current.scale || [1, 1, 1];
const deltaPos = [
pos[0] - (current.position?.[0] || 0),
pos[1] - (current.position?.[1] || 0),
pos[2] - (current.position?.[2] || 0),
];
const deltaRot = [
rot[0] - (current.rotation?.[0] || 0),
rot[1] - (current.rotation?.[1] || 0),
rot[2] - (current.rotation?.[2] || 0),
];
const safeScale = [
current.scale?.[0] || 1,
current.scale?.[1] || 1,
current.scale?.[2] || 1,
];
const deltaScale = [
safeScale[0] ? (scl[0] || 1) / safeScale[0] : scl[0] || 1,
safeScale[1] ? (scl[1] || 1) / safeScale[1] : scl[1] || 1,
safeScale[2] ? (scl[2] || 1) / safeScale[2] : scl[2] || 1,
];
selectionState.transform = {
position: [...pos],
rotation: [...rot],
scale: [...scl],
};
let idsToApply = selectionState.ids && selectionState.ids.length ? [...selectionState.ids] : [];
if (!idsToApply.length && selectionState.modelId) {
idsToApply = ["__model__"];
}
const isGroup = idsToApply.length > 1;
const pivot = selectionState.pivotCenter || [0, 0, 0];
let applied = false;
idsToApply.forEach((objectId) => {
const entity = objectId === "__model__"
? viewer.scene?.models?.[selectionState.modelId]
: resolveEntity(objectId, selectionState.modelId);
if (!entity) return;
const basePos = entity.position || [0, 0, 0];
const baseRot = entity.rotation || [0, 0, 0];
const baseScale = entity.scale || [1, 1, 1];
let newPos = [
basePos[0] + deltaPos[0],
basePos[1] + deltaPos[1],
basePos[2] + deltaPos[2],
];
if (isGroup) {
const local = [
basePos[0] - pivot[0],
basePos[1] - pivot[1],
basePos[2] - pivot[2],
];
// масштаб вокруг пивота
const scaled = [
local[0] * deltaScale[0],
local[1] * deltaScale[1],
local[2] * deltaScale[2],
];
const rotated = rotateVec3(scaled, deltaRot);
newPos = [
pivot[0] + rotated[0] + deltaPos[0],
pivot[1] + rotated[1] + deltaPos[1],
pivot[2] + rotated[2] + deltaPos[2],
];
}
entity.position = newPos;
entity.rotation = [
baseRot[0] + deltaRot[0],
baseRot[1] + deltaRot[1],
baseRot[2] + deltaRot[2],
];
entity.scale = [
baseScale[0] * deltaScale[0],
baseScale[1] * deltaScale[1],
baseScale[2] * deltaScale[2],
];
if (selectionState.modelId) {
const key = objectId === "__model__" ? "__model__" : (objectId || "__model__");
persistTransform(selectionState.modelId, key, {
position: [...entity.position],
rotation: [...entity.rotation],
scale: [...entity.scale],
});
}
applied = true;
});
if (!applied && selectionState.modelId && viewer.scene?.models?.[selectionState.modelId]) {
const model = viewer.scene.models[selectionState.modelId];
const basePos = model.position || [0, 0, 0];
const baseRot = model.rotation || [0, 0, 0];
const baseScale = model.scale || [1, 1, 1];
model.position = [
basePos[0] + deltaPos[0],
basePos[1] + deltaPos[1],
basePos[2] + deltaPos[2],
];
model.rotation = [
baseRot[0] + deltaRot[0],
baseRot[1] + deltaRot[1],
baseRot[2] + deltaRot[2],
];
model.scale = [
baseScale[0] * deltaScale[0],
baseScale[1] * deltaScale[1],
baseScale[2] * deltaScale[2],
];
persistTransform(selectionState.modelId, "__model__", {
position: [...model.position],
rotation: [...model.rotation],
scale: [...model.scale],
});
}
// при общем сдвиге переносим пивот
selectionState.pivotCenter = [
(pivot[0] || 0) + deltaPos[0],
(pivot[1] || 0) + deltaPos[1],
(pivot[2] || 0) + deltaPos[2],
];
updateTransformInputs(selectionState.transform);
scene.setAABBDirty?.();
scene.glRedraw?.();
};
const setSelection = (ids, modelId) => {
if (!viewer) return;
const scene = viewer.scene;
let uniqueIds = Array.from(new Set(ids || [])).filter(Boolean);
if ((!uniqueIds.length) && modelId && scene.models?.[modelId]) {
uniqueIds = Object.keys(scene.models[modelId].objects || {});
}
if (!uniqueIds.length && !modelId && activeModels.length) {
modelId = activeModels[0]?.model?.id || activeModels[0]?.id || null;
if (modelId && scene.models?.[modelId]) {
uniqueIds = Object.keys(scene.models[modelId].objects || {});
}
}
if (!uniqueIds.length) {
selectionState = null;
updateTransformInputs(null);
updateGlobalInputs(null);
gizmo?.clearTarget?.();
activeTransformTarget = null;
return;
}
lastSelection.ids = uniqueIds.slice(0);
lastSelection.modelId = modelId || null;
// если выбрали ровно один объект — работаем через гизму как раньше
if (uniqueIds.length === 1) {
const aabbSingle = getAABBForSelection(uniqueIds, modelId);
updateGlobalInputs(centerFromAABB(aabbSingle || []));
selectionState = null;
updateTransformInputs(null);
gizmo?.setTarget?.({ modelId, objectId: uniqueIds[0] });
activeTransformTarget = { modelId, objectId: uniqueIds[0] || "__model__" };
return;
}
const aabb = Array.from(scene.getAABB(uniqueIds) || []);
const pivotCenter = centerFromAABB(aabb);
const baseTransforms = new Map();
uniqueIds.forEach((id) => {
const ent = resolveEntity(id, modelId);
if (ent) {
baseTransforms.set(id, {
position: ent.position ? [...ent.position] : [0, 0, 0],
rotation: ent.rotation ? [...ent.rotation] : [0, 0, 0],
scale: ent.scale ? [...ent.scale] : [1, 1, 1],
});
}
});
if (baseTransforms.size === 0 && modelId && scene.models?.[modelId]) {
const model = scene.models[modelId];
baseTransforms.set("__model__", {
position: model.position ? [...model.position] : [0, 0, 0],
rotation: model.rotation ? [...model.rotation] : [0, 0, 0],
scale: model.scale ? [...model.scale] : [1, 1, 1],
});
}
const firstBase = baseTransforms.values().next().value || {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
};
const transform = {
position: [...(firstBase.position || [0, 0, 0])],
rotation: [...(firstBase.rotation || [0, 0, 0])],
scale: [...(firstBase.scale || [1, 1, 1])],
};
selectionState = {
ids: uniqueIds,
modelId,
pivotCenter,
baseTransforms,
transform,
};
activeTransformTarget = { modelId, objectId: uniqueIds[0] || "__model__" };
scene.setObjectsHighlighted(scene.highlightedObjectIds || [], false);
if (!isPointCloudModel(modelId)) {
scene.setObjectsHighlighted(uniqueIds, true);
}
updateTransformInputs(transform);
// Global center for group
updateGlobalInputs(centerFromAABB(aabb));
};
const collectObjectIds = (treeViewNode) => {
const ids = [];
if (!treeViewNode) return ids;
console.info("[DC Tree] collectObjectIds start", {
nodeId: treeViewNode.nodeId || treeViewNode.id,
title: treeViewNode.title || treeViewNode.text || treeViewNode.name
});
const plugin = treeViewNode.treeViewPlugin || treeView;
if (plugin?.withNodeTree) {
plugin.withNodeTree(treeViewNode, (node) => {
if (node.objectId) {
ids.push(node.objectId);
}
if (node.metaObjectId && viewer?.metaScene?.metaObjects?.[node.metaObjectId]) {
ids.push(...viewer.metaScene.metaObjects[node.metaObjectId].getObjectIDsInSubtree());
}
});
}
if (!ids.length && treeViewNode.metaObjectId && viewer?.metaScene?.metaObjects?.[treeViewNode.metaObjectId]) {
ids.push(...viewer.metaScene.metaObjects[treeViewNode.metaObjectId].getObjectIDsInSubtree());
}
if (!ids.length && treeViewNode.objectId) {
ids.push(treeViewNode.objectId);
}
// fallback: try meta by id
if (!ids.length && treeViewNode.id && viewer?.metaScene?.metaObjects?.[treeViewNode.id]) {
ids.push(...viewer.metaScene.metaObjects[treeViewNode.id].getObjectIDsInSubtree());
}
// last resort: all objects of model
if (!ids.length) {
const modelId = getModelIdFromNode(treeViewNode);
const model = modelId ? viewer?.scene?.models?.[modelId] : null;
if (model) {
ids.push(...Object.keys(model.objects || {}));
}
}
console.info("[DC Tree] collectObjectIds result", { count: ids.length, sample: ids.slice(0, 10) });
return Array.from(new Set(ids));
};
const resolveEntity = (objectId, modelId = null) => {
if (!viewer?.scene?.objects) return null;
const objects = viewer.scene.objects;
if (objects[objectId]) return objects[objectId];
const local = objectId.includes("#") ? objectId.split("#")[1] : objectId;
if (modelId) {
const globalId = `${modelId}#${local}`;
if (objects[globalId]) return objects[globalId];
}
if (objects[local]) return objects[local];
return null;
};
const parseInputNumber = (val) => {
if (val === undefined || val === null) return NaN;
const str = String(val).trim().replace(/,/g, ".");
const num = Number(str);
return Number.isFinite(num) ? num : NaN;
};
const persistTransform = (modelId, objectId, transform) => {
if (!modelId || !transform) return;
if (!transformStore[modelId]) transformStore[modelId] = {};
const key = objectId || "__model__";
transformStore[modelId][key] = {
position: transform.position ? [...transform.position] : [0, 0, 0],
rotation: transform.rotation ? [...transform.rotation] : [0, 0, 0],
scale: transform.scale ? [...transform.scale] : [1, 1, 1],
};
// Для LAS фиксируем трансформ на уровне модели, т.к. objectId может меняться между загрузками
if (modelTypes[modelId] === "las" && key !== "__model__") {
transformStore[modelId]["__model__"] = {
position: transform.position ? [...transform.position] : [0, 0, 0],
rotation: transform.rotation ? [...transform.rotation] : [0, 0, 0],
scale: transform.scale ? [...transform.scale] : [1, 1, 1],
};
}
};
const applyStoredTransformsToModel = (modelId) => {
if (!viewer || !modelId || !sceneModels[modelId]) return false;
const modelTransforms = transformStore[modelId];
if (!modelTransforms) return false;
let applied = false;
const entries = Object.entries(modelTransforms).filter(([, tr]) => !!tr);
const hasModel = !!modelTransforms.__model__;
const nonModel = entries.filter(([key]) => key !== "__model__");
const isNeutral = (tr = {}) => {
const pos = tr.position || [0, 0, 0];
const rot = tr.rotation || [0, 0, 0];
const scale = tr.scale || [1, 1, 1];
const nearZero = (v) => Math.abs(v) < 1e-6;
return (
pos.every(nearZero) &&
rot.every(nearZero) &&
scale.every((v) => Math.abs(v - 1) < 1e-6)
);
};
// Heuristic: если у GLB/GLTF сохранён единственный трансформ (root-объект) и __model__ пустой,
// считаем его трансформом всей модели. Это устраняет кейс, когда GLB при загрузке остаётся в нулях,
// пока не кликнешь по нему.
const shouldPromote =
(modelTypes[modelId] === "gltf") &&
nonModel.length === 1 &&
(!hasModel || isNeutral(modelTransforms.__model__));
const promoteKey = shouldPromote ? nonModel[0][0] : null;
if (shouldPromote) {
const tr = nonModel[0][1];
const model = sceneModels[modelId];
if (tr && model) {
if (tr.position) model.position = [...tr.position];
if (tr.rotation) model.rotation = [...tr.rotation];
if (tr.scale) model.scale = [...tr.scale];
applied = true;
}
}
// обычное применение: __model__ + остальные объекты (кроме промотнутого root, чтобы не удвоить эффект)
entries.forEach(([objectId, tr]) => {
if (!tr) return;
if (promoteKey && objectId === promoteKey) return;
if (shouldPromote && objectId === "__model__" && isNeutral(tr)) return;
const target =
objectId === "__model__"
? sceneModels[modelId]
: resolveEntity(objectId, modelId);
const applyTarget = target || sceneModels[modelId];
if (!applyTarget) return;
if (tr.position) applyTarget.position = [...tr.position];
if (tr.rotation) applyTarget.rotation = [...tr.rotation];
if (tr.scale) applyTarget.scale = [...tr.scale];
applied = true;
});
if (applied) {
viewer.scene?.setAABBDirty?.();
viewer.scene?.glRedraw?.();
}
return applied;
};
const getModelIdFromNode = (node) => {
const title = (node?.title || node?.text || node?.name || "").trim();
const firstObject = (node && collectObjectIds(node)[0]) || null;
return node?.modelId || node?.model?.id || node?.modelIdGiven || findModelIdForObjectId(firstObject) || (title && sceneModels[title] ? title : null);
};
const findModelIdForObjectId = (objectId) => {
if (!objectId || !viewer?.scene?.models) return null;
const models = viewer.scene.models;
for (const mid of Object.keys(models)) {
if (models[mid]?.objects && Object.prototype.hasOwnProperty.call(models[mid].objects, objectId)) {
return mid;
}
}
return null;
};
const highlightTreeNode = (treeViewNode, { fromTree = false } = {}) => {
if (!treeViewNode || !viewer) return;
const objectIds = collectObjectIds(treeViewNode);
const modelId = getModelIdFromNode(treeViewNode) || findModelIdForObjectId(objectIds[0]);
if (treeContainer) {
const prev = treeContainer.querySelector(".highlighted-node");
if (prev) prev.classList.remove("highlighted-node");
const nodeId = treeViewNode.nodeId || treeViewNode.id;
const liEl = nodeId ? treeContainer.querySelector(`li#${CSS.escape(nodeId)}`) : null;
if (liEl) {
liEl.classList.add("highlighted-node");
}
}
if (!fromTree) {
setSelection(objectIds, modelId);
}
};
const flyToObjectIds = (ids, modelId = null) => {
if (!viewer) return;
const scene = viewer.scene;
const idsToUse = (ids && ids.length) ? ids : (selectionState?.ids || []);
console.log("[DC Tree] flyToObjectIds start", {
count: idsToUse.length,
sample: idsToUse.slice(0, 10),
modelId
});
// фильтруем только существующие в сцене
const existing = idsToUse.filter((id) => scene?.objects?.[id]);
if (existing.length !== idsToUse.length) {
console.warn("[DC Tree] filtered missing ids", {
requested: idsToUse.length,
existing: existing.length
});
}
const idsForAABB = existing.length ? existing : idsToUse;
let aabb = null;
if (idsForAABB.length && scene?.getAABB) {
aabb = scene.getAABB(idsForAABB);
} else if (modelId && scene?.models?.[modelId]) {
aabb = scene.models[modelId].aabb || scene.getAABB({ ids: Object.keys(scene.models[modelId].objects || {}) });
}
const aabbArray = Array.from(aabb || []);
console.log("[DC Tree] AABB", aabbArray);
if (!aabbArray.length || aabbArray.length !== 6 || !aabbArray.every((n) => Number.isFinite(n))) {
console.warn("[DC Tree] no valid AABB for ids", { count: idsForAABB.length });
return;
}
const center = centerFromAABB(aabbArray);
if (viewer.cameraControl) {
viewer.cameraControl.pivotPos = center;
}
viewer.cameraFlight.flyTo({ aabb: aabbArray, duration: 0.6 });
rearmCameraControl();
};
const clearCustomTrees = () => {
customTrees.forEach((el) => el.remove());
customTrees.clear();
};
const renderFallbackTree = (modelId) => {
if (!treeContainer) return;
if (customTrees.has(modelId)) return;
const model = sceneModels[modelId];
if (!model) return;
const objectIds = Object.keys(model.objects || {});
const root = document.createElement("ul");
root.className = "custom-tree";
const liRoot = document.createElement("li");
liRoot.textContent = modelId;
root.appendChild(liRoot);
if (objectIds.length) {
const ul = document.createElement("ul");
objectIds.slice(0, 2000).forEach((oid) => {
const li = document.createElement("li");
const span = document.createElement("span");
span.textContent = oid;
li.appendChild(span);
ul.appendChild(li);
});
liRoot.appendChild(ul);
}
treeContainer.appendChild(root);
customTrees.set(modelId, root);
};
const hasMetaModel = (modelId) => {
return !!viewer?.metaScene?.metaModels?.[modelId];
};
const destroyAllModels = ({ preserveMeta = false } = {}) => {
activeModels.forEach(({ model, loader }) => {
try {
model?.destroy && model.destroy();
} catch (err) {
console.warn("destroy model failed", err);
}
try {
loader?.destroy && loader.destroy();
} catch (err) {
console.warn("destroy loader failed", err);
}
});
activeModels.length = 0;
Object.keys(sceneModels).forEach((key) => delete sceneModels[key]);
Object.keys(modelTypes).forEach((key) => delete modelTypes[key]);
pointCloudColorSnapshots.clear();
if (!preserveMeta) {
Object.keys(transformStore).forEach((key) => delete transformStore[key]);
}
if (blobUrls.length) {
blobUrls.forEach((u) => {
try {
URL.revokeObjectURL(u);
} catch (_) {
// noop
}
});
blobUrls.length = 0;
}
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
currentBlobUrl = null;
}
clearCustomTrees();
treeModelIds.clear();
try {
if (treeView?.destroy) {
treeView.destroy();
}
} catch (err) {
console.warn("treeView destroy failed", err);
}
treeView = null;
if (treeContainer?.replaceChildren) {
treeContainer.replaceChildren();
} else if (treeContainer) {
treeContainer.innerHTML = "";
}
if (sdkRef && viewer && treeContainer) {
resetTreeView(sdkRef);
}
// сбрасываем метамодели, чтобы не тянуть нейминг между проектами
if (viewer?.metaScene?.metaModels) {
try {
const ids = Object.keys(viewer.metaScene.metaModels);
ids.forEach((mid) => {
if (viewer.metaScene.destroyMetaModel) {
viewer.metaScene.destroyMetaModel(mid);
} else {
delete viewer.metaScene.metaModels[mid];
}
});
} catch (err) {
console.warn("metaScene cleanup failed", err);
}
}
setMeasureActive(false);
gizmo?.clearTarget();
treeView?.unShowNode?.();
clearMeasurements();
clearSectionPlanes();
selectionState = null;
lastSelection = { ids: [], modelId: null };
activeTransformTarget = null;
currentSelection = null;
if (!preserveMeta) {
currentProjectMeta = null;
lastAttemptedProjectId = null;
if (projectNameInput) {
projectNameInput.value = "";
projectNameInput.disabled = true;
}
}
updateNavCubeVisibility();
updatePanelsVisibility();
};
const getModelLabel = (modelId) => {
const entry = activeModels.find((m) => m?.model?.id === modelId);
if (!entry) return modelId;
return entry?.meta?.label || entry?.name || modelId;
};
const rebuildTreeModels = () => {
if (!viewer || !sdkRef) return;
treeModelIds.clear();
clearCustomTrees();
resetTreeView(sdkRef);
activeModels.forEach(({ model, name }) => {
const id = model?.id;
if (!id) return;
const metaPresent = hasMetaModel(id);
if (metaPresent && treeView?.addModel) {
try {
treeView.addModel(id, { rootName: name || id });
treeModelIds.add(id);
} catch (err) {
console.warn("tree addModel (rebuild)", err);
}
}
const hasNodes = treeContainer?.querySelector("li");
if (!metaPresent || !hasNodes) {
renderFallbackTree(id);
}
});
};
const deleteModelById = (modelId) => {
if (!modelId) return false;
const idx = activeModels.findIndex((m) => m?.model?.id === modelId);
if (idx === -1) return false;
const entry = activeModels[idx];
try {
entry.model?.destroy?.();
} catch (err) {
console.warn("destroy model failed", err);
}
try {
entry.loader?.destroy?.();
} catch (err) {
console.warn("destroy loader failed", err);
}
activeModels.splice(idx, 1);
delete sceneModels[modelId];
delete modelTypes[modelId];
pointCloudColorSnapshots.delete(modelId);
delete transformStore[modelId];
treeModelIds.delete(modelId);
if (customTrees.has(modelId)) {
try {
customTrees.get(modelId)?.remove?.();
} catch (_) {}
customTrees.delete(modelId);
}
if (viewer?.metaScene?.metaModels?.[modelId]) {
if (viewer.metaScene.destroyMetaModel) {
try {
viewer.metaScene.destroyMetaModel(modelId);
} catch (err) {
console.warn("meta destroy failed", err);
}
} else {
delete viewer.metaScene.metaModels[modelId];
}
}
if (selectionState?.modelId === modelId) {
selectionState = null;
updateTransformInputs(null);
updateGlobalInputs(null);
}
if (lastSelection?.modelId === modelId) {
lastSelection = { ids: [], modelId: null };
}
if (gizmo?.target?.modelId === modelId) {
gizmo.clearTarget();
}
activeTransformTarget = null;
currentSelection = null;
rebuildTreeModels();
updateNavCubeVisibility();
updatePanelsVisibility();
viewer?.scene?.setAABBDirty?.();
viewer?.scene?.glRedraw?.();
setStatus(`Модель ${modelId} удалена`);
return true;
};
const selectObject = ({ modelId, objectId }) => {
if (!viewer) return;
setSelection(objectId ? [objectId] : [], modelId);
if (objectId && treeView?.showNode) {
treeView.showNode(objectId);
}
};
const resetTreeView = (sdk) => {
if (treeView?.destroy) treeView.destroy();
treeView = new sdk.TreeViewPlugin(viewer, {
containerElement: treeContainer,
hierarchy: "containment",
autoAddModels: false,
pruneEmptyNodes: false,
autoExpandDepth: 2,
});
if (treeView?.on) {
treeView.on("nodeTitleClicked", (e) => {
const node = e?.treeViewNode || e?.node || e;
console.info("[DC Tree] nodeTitleClicked", {
nodeId: node?.nodeId || node?.id,
title: node?.title || node?.text || node?.name,
detail: e?.event?.detail
});
const ids = collectObjectIds(node);
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
setSelection(ids, modelId);
const clickDetail = (e?.event?.detail || e?.detail || 1);
if (clickDetail >= 2) {
flyToObjectIds(ids, modelId);
}
highlightTreeNode(node, { fromTree: true });
});
treeView.on("nodeTitleDoubleClicked", (e) => {
const node = e?.treeViewNode || e?.node || e;
console.info("[DC Tree] nodeTitleDoubleClicked", {
nodeId: node?.nodeId || node?.id,
title: node?.title || node?.text || node?.name
});
const ids = collectObjectIds(node);
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
highlightTreeNode(node, { fromTree: true });
flyToObjectIds(ids, modelId);
});
treeView.on("nodeDoubleClicked", (e) => {
const node = e?.treeViewNode || e?.node || e;
console.info("[DC Tree] nodeDoubleClicked", {
nodeId: node?.nodeId || node?.id,
title: node?.title || node?.text || node?.name
});
const ids = collectObjectIds(node);
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
highlightTreeNode(node, { fromTree: true });
flyToObjectIds(ids, modelId);
});
}
};
const createBlobSafeGLTFDataSource = () => ({
cacheBuster: false,
getGLTF: (src, ok, error) => {
if (src.startsWith("memory:") && memoryStore.has(src)) {
ok(memoryStore.get(src));
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getGLB: (src, ok, error) => {
if (src.startsWith("memory:") && memoryStore.has(src)) {
ok(memoryStore.get(src));
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getMetaModel: (src, ok, error) => {
if (src.startsWith("memory:") && memoryStore.has(src)) {
try {
const buffer = memoryStore.get(src);
const text = new TextDecoder().decode(new Uint8Array(buffer));
ok(JSON.parse(text));
} catch (err) {
error(err.message || err);
}
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(ok)
.catch((err) => error(err.message || err));
},
});
const createMemoryDataSource = () => ({
cacheBuster: false,
getXKT: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getGLTF: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getGLB: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getLAS: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getMetaModel: (src, ok, error) => {
if (memoryStore.has(src)) {
try {
const stored = memoryStore.get(src);
const bytes = cloneToUint8(stored) || new Uint8Array(0);
const text = new TextDecoder().decode(bytes);
ok(JSON.parse(text));
} catch (err) {
error(err.message || err);
}
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(ok)
.catch((err) => error(err.message || err));
},
getSTL: (src, ok, error) => {
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const cloned = cloneToUint8(stored);
ok(cloned ? cloned.buffer : stored);
return;
}
fetch(src)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
},
});
const loaderFor = (sdk, type, { cacheBuster = true, dataSource } = {}) => {
switch (type) {
case "xkt":
return new sdk.XKTLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.XKTDefaultDataSource({ cacheBuster }),
});
case "gltf":
return new sdk.GLTFLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.GLTFDefaultDataSource({ cacheBuster }),
});
case "bim":
return new sdk.DotBIMLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.DotBIMDefaultDataSource({ cacheBuster }),
});
case "las":
return new sdk.LASLoaderPlugin(viewer, {
fp64: true,
dataSource: dataSource,
});
case "obj":
return new sdk.OBJLoaderPlugin(viewer);
case "stl":
return new sdk.STLLoaderPlugin(viewer, {
dataSource: dataSource || new sdk.STLDefaultDataSource({ cacheBuster }),
});
default:
throw new Error(`Неподдерживаемый тип: ${type}`);
}
};
const guessTypeFromName = (name) => {
if (!name) return null;
const lower = name.toLowerCase();
if (lower.endsWith(".xkt")) return "xkt";
if (lower.endsWith(".glb") || lower.endsWith(".gltf")) return "gltf";
if (lower.endsWith(".bim")) return "bim";
if (lower.endsWith(".las") || lower.endsWith(".laz")) return "las";
if (lower.endsWith(".obj")) return "obj";
if (lower.endsWith(".stl")) return "stl";
return null;
};
const 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 fetchJSONWithTimeout = async (url, options = {}, timeoutMs = 5000) => {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetchJSON(url, {
...options,
signal: options.signal || controller.signal,
});
} finally {
window.clearTimeout(timer);
}
};
const uploadBuffer = async (bytes, filename) => {
const safeName = filename || "upload.bin";
const params = new URLSearchParams({ filename: safeName });
if (currentProjectMeta?.id) {
params.set("projectId", currentProjectMeta.id);
}
const res = await fetch(`/api/uploads?${params.toString()}`, {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: bytes
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = null;
}
if (!res.ok) {
throw new Error((data && data.message) || text || `Upload failed (${res.status})`);
}
if (!data || !data.src) {
throw new Error("Upload response is missing src");
}
return data.src;
};
const loadProjectsList = async () => {
try {
const list = await fetchJSON(API_BASE);
const templates = Array.isArray(list) ? list.filter((item) => item.type === "template") : [];
const projects = Array.isArray(list) ? list.filter((item) => item.type !== "template") : [];
projectIndex = { templates, projects };
templatesMenu?.setData(projectIndex);
return projectIndex;
} catch (err) {
const fallback = { templates: templateFallbacks, projects: [] };
projectIndex = fallback;
templatesMenu?.setData({ ...fallback, error: err.message || "API недоступно" });
throw err;
}
};
const fetchProjectById = async (id) => {
if (!id) {
throw new Error("Не указан ID проекта");
}
return fetchJSON(`${API_BASE}/${id}`);
};
const findProjectMetaById = (id) => {
if (!id) return null;
const all = [...(projectIndex.templates || []), ...(projectIndex.projects || [])];
const found = all.find((p) => p.id === id);
if (found) {
return { id: found.id, name: found.name, type: found.type };
}
return null;
};
const submitProjectRename = async () => {
if (isGuestMode()) return;
if (!projectNameInput || projectNameInput.disabled) return;
const newName = (projectNameInput.value || "").trim();
const targetId = currentProjectMeta?.id || lastAttemptedProjectId;
if (!targetId || !newName) {
if (!newName && projectNameError) projectNameError.textContent = "";
return;
}
if (projectNameError) projectNameError.textContent = "";
try {
const updated = await fetchJSON(`${API_BASE}/${targetId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName })
});
currentProjectMeta = { ...(currentProjectMeta || {}), id: targetId, name: updated.name || newName, type: updated.type || currentProjectMeta?.type || "project" };
lastAttemptedProjectId = currentProjectMeta.id;
await loadProjectsList().catch(() => {});
setStatus("Название проекта обновлено");
} catch (err) {
console.error(err);
if (projectNameError) projectNameError.textContent = err.message || "Не удалось обновить название";
}
};
const safeClone = (value) => {
try {
return JSON.parse(JSON.stringify(value || {}));
} catch (err) {
return {};
}
};
const isRemoteSrc = (src) => {
if (!src || typeof src !== "string") return false;
return !(src.startsWith("memory:") || src.startsWith("blob:"));
};
const cloneToUint8 = (data) => {
if (!data) return new Uint8Array(0);
if (data instanceof Uint8Array) {
return data.slice();
}
if (data instanceof ArrayBuffer) {
try {
return new Uint8Array(data.slice(0));
} catch (err) {
return new Uint8Array(0);
}
}
try {
return new Uint8Array(data);
} catch (err) {
return new Uint8Array(0);
}
};
const arrayBufferToBase64 = (buffer) => {
const bytes = cloneToUint8(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
};
const getBufferForSrc = async (src) => {
if (!src) return null;
if (memoryStore.has(src)) {
const stored = memoryStore.get(src);
const bytes = cloneToUint8(stored);
return bytes;
}
try {
const res = await fetch(src);
if (!res.ok) return null;
const buf = await res.arrayBuffer();
return cloneToUint8(buf);
} catch (err) {
return null;
}
};
const snapshotVec = (v) => {
if (!v) return null;
if (Array.isArray(v)) return [...v];
if (ArrayBuffer.isView(v)) return Array.from(v);
return null;
};
const buildProjectSnapshot = async (name) => {
const models = [];
const transformsCopy = safeClone(transformStore);
activeModels.forEach(({ model }) => {
if (!model || !model.id) return;
if (!transformsCopy[model.id]) transformsCopy[model.id] = {};
if (!transformsCopy[model.id]["__model__"]) {
transformsCopy[model.id]["__model__"] = {
position: snapshotVec(model.position) || [0, 0, 0],
rotation: snapshotVec(model.rotation) || [0, 0, 0],
scale: snapshotVec(model.scale) || [1, 1, 1],
};
}
});
for (const entry of activeModels) {
const model = entry.model;
const modelType = normalizeType(entry.type, entry.src) || normalizeType(guessTypeFromName(entry.src)) || entry.type || null;
const src = typeof entry.src === "string" ? entry.src : "";
const meta = entry.meta || {};
const baseModel = {
id: model.id,
type: modelType,
meta: {
label: meta.label || entry.name || model.id,
metadataPath: meta.metadataPath || null,
settingsSrc: getModelSettingsSrc(entry) || null,
sourceSrc: meta.sourceSrc || meta.conversion?.sourceSrc || meta.manifest?.sourceSrc || null,
artifactSrc: meta.artifactSrc || null
},
visible: model.visible !== false
};
if (!modelType) {
throw new Error("Не удалось определить тип модели для сохранения.");
}
if (isRemoteSrc(src)) {
models.push({ ...baseModel, src });
continue;
}
const cacheKey = src || `${model.id}-${modelType}`;
if (uploadCache.has(cacheKey)) {
models.push({ ...baseModel, src: uploadCache.get(cacheKey) });
continue;
}
const buffer = await getBufferForSrc(src);
if (!buffer) {
throw new Error("Нельзя сохранить проект: отсутствуют данные загруженного файла (blob/memory).");
}
const bytes = cloneToUint8(buffer);
const filename = (entry.name && entry.name.includes(".")) ? entry.name : `${model.id || "model"}.${modelType || "bin"}`;
const uploadedSrc = await uploadBuffer(bytes, filename);
uploadCache.set(cacheKey, uploadedSrc);
models.push({
...baseModel,
src: uploadedSrc
});
}
const viewerState = viewer ? {
camera: viewer.camera ? {
eye: snapshotVec(viewer.camera.eye),
look: snapshotVec(viewer.camera.look),
up: snapshotVec(viewer.camera.up),
projection: viewer.camera.projection
} : null,
transparent: !!toggleTransparent?.checked,
edges: !!toggleEdges?.checked,
addMode: !!addMode?.checked,
displayMode: activeDisplayMode,
lightingMode: activeLightingMode,
navigationAxis: activeNavigationAxis,
zoomSpeed: activeZoomSpeed,
selection: {
ids: lastSelection.ids || [],
modelId: lastSelection.modelId || null,
},
models: activeModels.map((entry) => ({
id: entry.model?.id,
visible: entry.model?.visible !== false
})),
transforms: transformsCopy
} : {};
const design = settingsMenu?.getState ? settingsMenu.getState() : {};
return {
name,
type: "project",
models,
viewerState,
design,
meta: {
description: "",
tags: []
}
};
};
const applyDesignState = (design) => {
if (!design || !settingsMenu?.applyState) return;
settingsMenu.applyState(design);
applyToolbarVars();
applyHighlightTheme();
applyMeasureTheme();
if (sdkRef) {
rebuildNavCube(sdkRef);
}
};
const applyCameraSnapshot = (cameraState) => {
if (!viewer?.camera || !cameraState || typeof cameraState !== "object") return;
const { eye, look, up, projection } = cameraState;
if (Array.isArray(eye) && eye.length === 3) viewer.camera.eye = [...eye];
if (Array.isArray(look) && look.length === 3) viewer.camera.look = [...look];
if (Array.isArray(up) && up.length === 3) viewer.camera.up = [...up];
if (projection) viewer.camera.projection = projection;
updateProjectionButton();
viewer.scene?.glRedraw?.();
};
const buildModelViewerSettings = () => ({
version: 1,
updatedAt: new Date().toISOString(),
viewerState: viewer ? {
camera: viewer.camera ? {
eye: snapshotVec(viewer.camera.eye),
look: snapshotVec(viewer.camera.look),
up: snapshotVec(viewer.camera.up),
projection: viewer.camera.projection
} : null,
transparent: !!toggleTransparent?.checked,
edges: !!toggleEdges?.checked,
displayMode: activeDisplayMode,
lightingMode: activeLightingMode,
navigationAxis: activeNavigationAxis,
zoomSpeed: activeZoomSpeed
} : {},
design: settingsMenu?.getState ? settingsMenu.getState() : {}
});
const applyModelViewerSettings = (settings, { applyCamera = true, applyDesign = true } = {}) => {
if (!settings || typeof settings !== "object") return;
const viewerState = settings.viewerState && typeof settings.viewerState === "object"
? settings.viewerState
: {};
if (applyDesign && settings.design && typeof settings.design === "object") {
applyDesignState(settings.design);
}
if (!viewer) return;
if (typeof viewerState.transparent === "boolean" && toggleTransparent) {
toggleTransparent.checked = viewerState.transparent;
viewer.transparent = viewerState.transparent;
if (viewer.scene?.canvas) {
viewer.scene.canvas.transparent = viewerState.transparent;
}
}
if (typeof viewerState.edges === "boolean" && toggleEdges) {
suppressEdgeReload = true;
toggleEdges.checked = viewerState.edges;
suppressEdgeReload = false;
}
if (typeof viewerState.displayMode === "string") {
activeDisplayMode = viewerState.displayMode;
applyDisplayMode();
}
if (typeof viewerState.lightingMode === "string") {
activeLightingMode = viewerState.lightingMode;
applyLightingMode();
}
if (typeof viewerState.navigationAxis === "string") {
activeNavigationAxis = viewerState.navigationAxis;
applyNavigationAxis({ resetAuto: false });
}
if (viewerState.zoomSpeed !== undefined) {
setZoomSpeed(viewerState.zoomSpeed);
}
if (applyCamera) {
applyCameraSnapshot(viewerState.camera);
}
rearmCameraControl();
};
const fetchModelSettings = async (src) => {
const normalized = normalizeModelSettingsSrc(src);
if (!normalized) return null;
const params = new URLSearchParams({ src: normalized });
reportViewerEvent("model-settings-request", { src: normalized });
const data = await fetchJSONWithTimeout(`${MODEL_SETTINGS_API}?${params.toString()}`, {}, 2500);
reportViewerEvent("model-settings-response", {
src: data?.sourceSrc || normalized,
hasViewerSettings: !!data?.viewerSettings,
});
return data;
};
const saveCurrentModelSettings = async () => {
if (isGuestMode()) {
setStatus("Гостевой просмотр не сохраняет изменения");
return;
}
const entry = getPrimaryModelEntry();
const settingsSrc = getModelSettingsSrc(entry);
if (!entry || !settingsSrc) {
setStatus("Настройки можно сохранить только для модели из Beam-хранилища");
return;
}
const previousLabel = saveModelSettingsButton?.textContent || "Сохранить настройки";
if (saveModelSettingsButton) {
saveModelSettingsButton.disabled = true;
saveModelSettingsButton.textContent = "Сохраняем...";
}
try {
const payload = { viewerSettings: buildModelViewerSettings() };
const params = new URLSearchParams({ src: settingsSrc });
const saved = await fetchJSON(`${MODEL_SETTINGS_API}?${params.toString()}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
entry.meta = {
...(entry.meta || {}),
settingsSrc: saved?.sourceSrc || settingsSrc,
sourceSrc: saved?.sourceSrc || entry.meta?.sourceSrc || null,
viewerSettings: saved?.viewerSettings || payload.viewerSettings
};
setStatus("Настройки модели сохранены");
log(`Настройки модели сохранены: ${entry.name || entry.model?.id || settingsSrc}`, false);
} catch (err) {
console.error(err);
showError(err.message || "Не удалось сохранить настройки модели");
} finally {
if (saveModelSettingsButton) {
saveModelSettingsButton.textContent = previousLabel;
}
updatePanelsVisibility();
}
};
const applyViewerState = (state) => {
if (!viewer || !state || typeof state !== "object") return;
if (state.camera && viewer.camera) {
applyCameraSnapshot(state.camera);
}
if (typeof state.transparent === "boolean" && toggleTransparent) {
toggleTransparent.checked = state.transparent;
toggleTransparent.dispatchEvent(new Event("change"));
}
if (typeof state.edges === "boolean" && toggleEdges) {
suppressEdgeReload = true;
toggleEdges.checked = state.edges;
toggleEdges.dispatchEvent(new Event("change"));
suppressEdgeReload = false;
}
if (typeof state.addMode === "boolean" && addMode) {
addMode.checked = state.addMode;
}
if (typeof state.displayMode === "string") {
activeDisplayMode = state.displayMode;
applyDisplayMode();
}
if (typeof state.lightingMode === "string") {
activeLightingMode = state.lightingMode;
applyLightingMode();
}
if (typeof state.navigationAxis === "string") {
activeNavigationAxis = state.navigationAxis;
applyNavigationAxis({ resetAuto: false });
}
if (state.zoomSpeed !== undefined) {
setZoomSpeed(state.zoomSpeed);
}
if (state.transforms && typeof state.transforms === "object") {
Object.keys(transformStore).forEach((k) => delete transformStore[k]);
Object.assign(transformStore, safeClone(state.transforms));
Object.keys(sceneModels).forEach((mid) => applyStoredTransformsToModel(mid));
}
if (Array.isArray(state.models)) {
state.models.forEach((m) => {
if (!m || !m.id) return;
const model = sceneModels[m.id];
if (model && typeof m.visible === "boolean") {
model.visible = m.visible;
}
});
}
if (state.selection) {
const ids = Array.isArray(state.selection.ids) ? state.selection.ids : [];
const filtered = ids.filter((id) => viewer.scene?.objects?.[id]);
if (filtered.length) {
setSelection(filtered, state.selection.modelId || null);
} else {
selectionState = null;
gizmo?.clearTarget?.();
updateTransformInputs(null);
updateGlobalInputs(null);
}
}
if (deleteProjectButton) {
deleteProjectButton.disabled = !currentProjectMeta;
}
if (projectNameInput) {
projectNameInput.value = currentProjectMeta?.name || state.name || "";
projectNameInput.disabled = !(currentProjectMeta || lastAttemptedProjectId);
}
rearmCameraControl();
};
const initViewer = async () => {
// remove preload hidden style ASAP
const preloadHidden = document.getElementById("preload-hidden");
if (preloadHidden) {
preloadHidden.remove();
}
document.body.style.opacity = "1";
await initializeRuntimeMode();
const sdk = await import(SDK_URL);
sdkRef = sdk;
const { Viewer, NavCubePlugin, DistanceMeasurementsPlugin, DistanceMeasurementsMouseControl, SectionPlanesPlugin } = sdk;
mathUtil = sdk.math;
if (headerEl && homeButton && !homeButton.querySelector(".ue-logo")) {
const logoEl = createLogo();
homeButton.appendChild(logoEl);
if (logoEl.style.width) homeButton.style.width = logoEl.style.width;
if (logoEl.style.height) homeButton.style.height = logoEl.style.height;
homeButton.style.border = "none";
homeButton.style.background = "transparent";
homeButton.style.padding = "0";
}
// Отключаем cacheBuster для glTF/GLB повсюду, чтобы не ломать blob: URL
if (sdk.GLTFDefaultDataSource) {
sdk.GLTFDefaultDataSource.prototype._cacheBusterURL = function (url) {
return url;
};
}
// Для blob: используем fetch вместо XHR внутри sdk.utils.loadArraybuffer
if (sdk.utils && typeof sdk.utils.loadArraybuffer === "function") {
const originalLoadArraybuffer = sdk.utils.loadArraybuffer;
sdk.utils.loadArraybuffer = (url, ok, error) => {
if (typeof url === "string" && url.startsWith("memory:") && memoryStore.has(url)) {
ok(memoryStore.get(url));
return;
}
if (typeof url === "string" && url.startsWith("blob:")) {
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.arrayBuffer();
})
.then(ok)
.catch((err) => error(err.message || err));
} else {
originalLoadArraybuffer(url, ok, error);
}
};
}
if (sdk.LASDefaultDataSource) {
sdk.LASDefaultDataSource.prototype._cacheBusterURL = function (url) {
return url;
};
}
const viewerContextAttr = {};
await waitForViewerCanvasReady(viewerContextAttr);
viewer = new Viewer({
canvasId: "viewerCanvas",
transparent: true,
contextAttr: viewerContextAttr,
dtxEnabled: true,
colorTextureEnabled: true,
});
configureCameraControl();
defaultCameraWorldAxis = snapshotVec(viewer.camera?.worldAxis) || [...NAVIGATION_AXIS_PRESETS["y-up"].worldAxis];
defaultCameraUp = snapshotVec(viewer.camera?.up) || [...NAVIGATION_AXIS_PRESETS["y-up"].up];
if (viewer.scene?.highlightMaterial) {
const hexToRGB = (hex) => {
const h = hex.replace("#", "");
const bigint = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);
const r = (bigint >> 16) & 255;
const g = (bigint >> 8) & 255;
const b = bigint & 255;
return [r / 255, g / 255, b / 255];
};
const hl = getComputedStyle(document.documentElement).getPropertyValue("--highlight-color").trim() || "#b833ff";
const color = hexToRGB(hl);
viewer.scene.highlightMaterial.color = color;
if (viewer.scene.highlightMaterial.edgeColor) {
viewer.scene.highlightMaterial.edgeColor = color;
}
if (viewer.scene.highlightMaterial.fillColor) {
viewer.scene.highlightMaterial.fillColor = [...color, 0.5];
}
}
if (SectionPlanesPlugin && sectionOverviewCanvas) {
sectionPlanesPlugin = new SectionPlanesPlugin(viewer, {
overviewCanvasId: "sectionPlanesOverview",
overviewVisible: false,
});
sectionPlanesPlugin.setOverviewVisible(false);
applySectionPlaneTheme();
setSectionModeActive(false);
} else {
console.warn("SectionPlanesPlugin is not available in the loaded SDK build");
if (sectionButton) {
sectionButton.classList.add("disabled");
sectionButton.disabled = true;
sectionButton.title = "Разрез недоступен";
}
}
if (DistanceMeasurementsPlugin && DistanceMeasurementsMouseControl) {
measurePlugin = new DistanceMeasurementsPlugin(viewer, {
defaultAxisVisible: false,
defaultLabelsOnWires: true,
});
measureControl = new DistanceMeasurementsMouseControl(measurePlugin, {});
measureControl.snapping = true;
measurePlugin.on?.("measurementCreated", applyMeasureTheme);
if (measureModal) {
measurementModal = createMeasurementModal({
panel: measureModal,
backdrop: measureModalBackdrop,
onClose: () => {
activeMeasurementId = null;
},
onDelete: (id) => {
if (!measurePlugin || !id) return;
measurePlugin.destroyMeasurement(id);
closeMeasurementModal();
},
onToggleVisibility: (id, nextVisible) => {
const measurement = measurePlugin?.measurements?.[id];
if (!measurement) return;
measurement.visible = nextVisible;
measurementModal?.setVisibility?.(measurement.visible);
},
});
measurePlugin.on?.("contextMenu", (evt) => {
const measurement = evt?.measurement || evt?.distanceMeasurement;
if (!measurement) return;
evt.event?.preventDefault?.();
activeMeasurementId = measurement.id;
measurementModal?.show?.({
measurementId: measurement.id,
visible: measurement.visible !== false,
});
});
}
applyMeasureTheme();
setMeasureActive(false);
} else {
console.warn("DistanceMeasurementsPlugin is not available in the loaded SDK build");
if (measureButton) {
measureButton.classList.add("disabled");
measureButton.title = "Линейка недоступна";
}
}
viewer.transparent = toggleTransparent.checked;
if (viewer.scene?.canvas) {
viewer.scene.canvas.transparent = toggleTransparent.checked;
}
applyLightingMode();
updateProjectionButton();
if (mainCanvas && !mainCanvas.hasAttribute("tabindex")) {
mainCanvas.setAttribute("tabindex", "0");
}
if (mainCanvas) {
mainCanvas.addEventListener("pointerdown", () => {
focusCanvasAndControls();
}, true);
mainCanvas.addEventListener("pointerenter", scheduleCameraRearm);
}
rebuildNavCube(sdk);
resetTreeView(sdk);
applyToolbarVars();
applyHighlightTheme();
updatePanelsVisibility();
ensureCameraControlActive();
gizmo = new TransformGizmo({
viewer,
sdk,
treeView,
transformStore,
sceneModels,
onTargetChange: (data) => {
if (!data) {
updateTransformInputs(null);
return;
}
updateTransformInputs(data.transform);
},
onTransformChange: (payload) => {
const { modelId, objectId, transform } = payload;
persistTransform(modelId, objectId || "__model__", transform);
if (selectionState) {
applyGroupTransform({
position: transform.position,
rotation: transform.rotation,
scale: transform.scale,
});
} else {
// одиночный объект через гизмо
const model = sceneModels[modelId];
if (model) {
model.position = transform.position ? [...transform.position] : model.position;
model.rotation = transform.rotation ? [...transform.rotation] : model.rotation;
model.scale = transform.scale ? [...transform.scale] : model.scale;
updateTransformInputs(transform);
viewer.scene.setAABBDirty?.();
viewer.scene.glRedraw?.();
}
}
},
});
// Пик по сцене: ЛКМ по объекту -> выделение (ПКМ контекст меню отключено)
const canvasEl = viewer.scene?.canvas?.canvas;
if (canvasEl) {
let downPos = null;
canvasEl.addEventListener("contextmenu", (event) => {
event.preventDefault();
});
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) => {
const debugOptions = options || {};
try {
const { type, url, name = "", replace = true, id: forcedId, meta, edges } = options;
const modelSettings = meta?.viewerSettings;
const normalizedType = normalizeType(type, typeof url === "string" ? url : "");
const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : ""));
if (!inferredType || !acceptByType[inferredType]) {
reportViewerEvent("load-model-unsupported", { type, url, name, normalizedType, inferredType }, "error");
showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl");
return null;
}
if (replace) {
destroyAllModels({ preserveMeta: isLoadingSavedProject });
}
if (modelSettings) {
applyModelViewerSettings(modelSettings, { applyCamera: false });
}
setStatus(`Загрузка: ${name || inferredType}...`);
setLoading(true);
const isBlob = typeof url === "string" && url.startsWith("blob:");
const isMemory = typeof url === "string" && url.startsWith("memory:");
log(`loader: ${inferredType}, blob=${isBlob}, memory=${isMemory}, url=${url}`, false);
reportViewerEvent("load-model-start", {
inferredType,
isBlob,
isMemory,
name,
replace,
url,
});
const xktDataSource = inferredType === "xkt"
? (isBlob || isMemory ? createMemoryDataSource() : new sdk.XKTDefaultDataSource({ cacheBuster: !isBlob }))
: null;
const gltfDataSource = inferredType === "gltf"
? (isBlob || isMemory ? createMemoryDataSource() : new sdk.GLTFDefaultDataSource({ cacheBuster: false }))
: null;
const lasDataSource = inferredType === "las"
? (isBlob || isMemory ? createMemoryDataSource() : null)
: null;
const stlDataSource = inferredType === "stl"
? (isBlob || isMemory ? createMemoryDataSource() : null)
: null;
const currentLoader = loaderFor(sdk, inferredType, {
cacheBuster: isBlob ? false : true,
dataSource: xktDataSource || gltfDataSource || lasDataSource || stlDataSource || undefined,
});
const id = forcedId || (replace ? "model" : `model-${++modelCounter}`);
let srcToLoad = url;
if (inferredType === "obj" && typeof url === "string" && url.startsWith("memory:") && memoryStore.has(url)) {
const blobUrl = URL.createObjectURL(new Blob([memoryStore.get(url)], { type: "text/plain" }));
blobUrls.push(blobUrl);
srcToLoad = blobUrl;
}
const loadOpts = {
id,
src: srcToLoad,
edges: edges ?? (inferredType === "gltf" || inferredType === "las" ? false : toggleEdges.checked),
};
if (inferredType === "gltf") loadOpts.autoMetaModel = true;
if (inferredType === "bim" || inferredType === "las") loadOpts.rotation = [-90, 0, 0];
if (inferredType === "stl") loadOpts.smoothNormals = true;
const currentModel = currentLoader.load(loadOpts);
sceneModels[id] = currentModel;
modelTypes[id] = inferredType;
currentModel.on("loaded", () => {
reportViewerEvent("model-loaded", {
id,
inferredType,
name,
objectCount: Object.keys(viewer.scene.objects || {}).length,
src: srcToLoad,
});
setTimeout(() => applyStoredTransformsToModel(id), 0);
setTimeout(() => applyStoredTransformsToModel(id), 60);
const hasModelCamera = !!modelSettings?.viewerState?.camera;
if (!isLoadingSavedProject && !hasModelCamera) {
viewer.cameraFlight.flyTo({ aabb: viewer.scene.getAABB({}) });
}
if (modelSettings) {
setTimeout(() => applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: false }), 0);
}
setStatus(`Загружено: ${name || inferredType}`);
dropZone.classList.add("hidden");
setLoading(false);
if (inferredType === "las") {
snapshotPointCloudColors(id);
}
applyDisplayModeToModel(id);
updateNavCubeVisibility();
updatePanelsVisibility();
rearmCameraControl();
// добавляем в дерево только если есть метамодель
setTimeout(() => {
const metaPresent = hasMetaModel(id);
if (metaPresent && !treeModelIds.has(id) && treeView?.addModel) {
try {
treeView.addModel(id, { rootName: name || id });
treeModelIds.add(id);
} catch (err) {
console.warn("tree addModel", err);
}
}
const hasNodes = treeContainer?.querySelector("li");
if (!metaPresent || !hasNodes) {
renderFallbackTree(id);
}
}, 250);
});
currentModel.on("error", (err) => {
reportViewerEvent("model-error", {
id,
inferredType,
name,
error: err?.message || String(err),
src: srcToLoad,
}, "error");
showError(`Ошибка загрузки: ${err}`);
setLoading(false);
updateNavCubeVisibility();
updatePanelsVisibility();
});
activeModels.push({
model: currentModel,
loader: currentLoader,
type: inferredType,
src: typeof url === "string" ? url : srcToLoad,
name,
meta
});
updateNavCubeVisibility();
updatePanelsVisibility();
// если есть сохранённые трансформы для модели — применим
applyStoredTransformsToModel(id);
return currentModel;
} catch (e) {
console.error(e);
reportViewerEvent("load-model-exception", {
type: debugOptions.type,
url: debugOptions.url,
name: debugOptions.name,
error: e?.message || String(e),
}, "error");
showError(e.message || "Ошибка загрузки");
setLoading(false);
return null;
}
};
const loadModelAsync = (options) => new Promise((resolve, reject) => {
const model = loadModel(options);
if (!model || !model.on) {
reject(new Error("Не удалось инициировать загрузку модели"));
return;
}
model.on("loaded", () => resolve(model));
model.on("error", (err) => reject(new Error(err?.message || err)));
});
const loadStartupModelFromQuery = async () => {
const params = new URLSearchParams(window.location.search);
const sharedViewer = startupSharePayload?.viewer || null;
const src = sharedViewer?.src || params.get("url") || params.get("src");
reportViewerEvent("startup-query", {
search: window.location.search,
mode: viewerMode,
hasShare: !!sharedViewer,
hasUrl: params.has("url"),
hasSrc: params.has("src"),
src,
typeParam: sharedViewer?.type || params.get("type"),
nameParam: sharedViewer?.name || params.get("name"),
});
if (!src) {
reportViewerEvent("startup-no-src", { search: window.location.search }, "error");
return;
}
const name = sharedViewer?.name || params.get("name") || src.split("/").pop() || "model";
const type = normalizeType(sharedViewer?.type || params.get("type"), src) || normalizeType(guessTypeFromName(name));
if (!type) {
reportViewerEvent("startup-type-error", { src, name, typeParam: params.get("type") }, "error");
showError("Не удалось определить формат модели из URL");
return;
}
dropZone?.classList.add("hidden");
setStatus(`Подготовка: ${name}...`);
setLoading(true);
let settingsSrc = sharedViewer?.settingsSrc || params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src;
const applyQueryViewerOverrides = () => {
if (params.has("displayMode")) {
activeDisplayMode = params.get("displayMode") || "source";
setDisplayModeControlState();
}
if (params.has("lightingMode") || params.has("light")) {
activeLightingMode = params.get("lightingMode") || params.get("light") || "ambient";
applyLightingMode();
}
if (params.has("navigationAxis") || params.has("axis")) {
activeNavigationAxis = params.get("navigationAxis") || params.get("axis") || "auto";
applyNavigationAxis({ resetAuto: false });
}
if (params.has("zoomSpeed")) {
setZoomSpeed(params.get("zoomSpeed"));
}
};
applyQueryViewerOverrides();
const startupMeta = {
label: name,
source: sharedViewer ? "share" : "query",
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src),
artifactSrc: normalizeModelSettingsSrc(src),
assetId: sharedViewer?.assetId || null,
versionId: sharedViewer?.versionId || null,
shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(),
viewerSettings: null,
};
const applyFetchedStartupSettings = (settingsResponse) => {
const modelSettings = settingsResponse?.viewerSettings || null;
settingsSrc = settingsResponse?.sourceSrc || settingsSrc;
reportViewerEvent("startup-settings-loaded", {
settingsSrc,
hasViewerSettings: !!modelSettings,
});
if (!modelSettings) return;
const normalizedSettingsSrc = normalizeModelSettingsSrc(settingsSrc) || startupMeta.settingsSrc;
startupMeta.settingsSrc = normalizedSettingsSrc;
startupMeta.viewerSettings = modelSettings;
const entry = activeModels.find((item) => item?.meta === startupMeta) || getPrimaryModelEntry();
if (entry) {
entry.meta = {
...(entry.meta || {}),
settingsSrc: normalizedSettingsSrc,
sourceSrc: normalizedSettingsSrc,
artifactSrc: startupMeta.artifactSrc,
viewerSettings: modelSettings,
};
}
applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: true });
applyQueryViewerOverrides();
};
fetchModelSettings(settingsSrc)
.then(applyFetchedStartupSettings)
.catch((err) => {
console.warn("model settings unavailable", err);
reportViewerEvent("startup-settings-unavailable", {
settingsSrc,
error: err?.message || String(err),
});
});
if (params.has("edges")) {
startupMeta.viewerSettings = {
viewerState: {
edges: params.get("edges") !== "false"
}
};
}
reportViewerEvent("startup-load-model", {
src,
name,
type,
settingsSrc,
hasViewerSettings: false,
});
loadModel({
type,
url: src,
name,
replace: params.get("replace") !== "false",
edges: params.has("edges") ? params.get("edges") !== "false" : undefined,
meta: startupMeta,
});
};
const startupModelLoadPromise = loadStartupModelFromQuery().catch((err) => {
console.warn("startup model load failed", err);
reportViewerEvent("startup-load-failed", {
message: err?.message || String(err),
stack: err?.stack,
}, "error");
showError(err.message || "Не удалось загрузить модель из URL");
});
const loadProjectSnapshot = async (project) => {
if (!project) {
throw new Error("Нет данных проекта");
}
isLoadingSavedProject = true;
suppressEdgeReload = true;
try {
destroyAllModels({ preserveMeta: false });
currentProjectMeta = { id: project.id, name: project.name, type: project.type };
if (projectNameInput) {
projectNameInput.disabled = false;
projectNameInput.value = project.name || "";
}
Object.keys(transformStore).forEach((k) => delete transformStore[k]);
const savedTransforms = project.viewerState?.transforms || project.viewerState?.transformStore;
if (savedTransforms) {
Object.assign(transformStore, safeClone(savedTransforms));
}
const models = Array.isArray(project.models) ? project.models : [];
if (!models.length) {
applyViewerState(project.viewerState);
applyDesignState(project.design);
updatePanelsVisibility();
return;
}
for (let i = 0; i < models.length; i++) {
const modelCfg = models[i] || {};
const modelId = modelCfg.id || (i === 0 ? "model" : `model-${i + 1}`);
const modelType = normalizeType(modelCfg.type, modelCfg.src) || normalizeType(guessTypeFromName(modelCfg.src));
if (!modelType) {
throw new Error(`Не удалось определить тип модели ${modelId}`);
}
await loadModelAsync({
type: modelType,
url: modelCfg.src,
name: modelCfg.meta?.label || modelCfg.name || modelId,
replace: i === 0,
id: modelId,
meta: modelCfg.meta
});
}
applyViewerState(project.viewerState);
applyDesignState(project.design);
// Повторно накатываем трансформы чуть позже, если загрузчики внутри что-то сбросили
setTimeout(() => {
Object.keys(sceneModels).forEach((mid) => applyStoredTransformsToModel(mid));
}, 50);
setStatus(`Проект "${project.name}" загружен`);
templatesMenu?.hide();
} finally {
isLoadingSavedProject = false;
suppressEdgeReload = false;
updatePanelsVisibility();
rearmCameraControl();
}
};
const loadProjectFromServer = async (projectId) => {
try {
lastAttemptedProjectId = projectId;
const meta = findProjectMetaById(projectId) || { id: projectId, name: "", type: "project" };
currentProjectMeta = meta;
if (deleteProjectButton) deleteProjectButton.disabled = false;
if (projectNameInput) {
projectNameInput.disabled = false;
projectNameInput.value = meta.name || "";
}
updatePanelsVisibility();
setStatus("Загрузка проекта...");
setLoading(true);
const project = await fetchProjectById(projectId);
currentProjectMeta = { id: project.id, name: project.name, type: project.type };
if (deleteProjectButton) deleteProjectButton.disabled = false;
await loadProjectSnapshot(project);
} catch (err) {
console.error(err);
showError(err.message || "Не удалось загрузить проект");
} finally {
setLoading(false);
updatePanelsVisibility();
}
};
const closeSaveModal = () => {
saveProjectBackdrop?.classList.add("hidden");
saveProjectModal?.classList.add("hidden");
if (saveProjectError) saveProjectError.textContent = "";
if (saveProjectConfirm) {
saveProjectConfirm.disabled = false;
saveProjectConfirm.textContent = "Сохранить";
}
if (saveProjectDuplicate) {
saveProjectDuplicate.disabled = false;
saveProjectDuplicate.textContent = "Сохранить как новый";
}
if (saveProjectHint) {
saveProjectHint.textContent = "Введите имя, чтобы сохранить проект в хранилище";
}
};
const openSaveModal = () => {
if (isGuestMode()) return;
if (!activeModels.length && !currentProjectMeta) return;
const defaultName = currentProjectMeta?.name || "Новый проект";
if (saveProjectNameInput) {
saveProjectNameInput.value = defaultName;
saveProjectNameInput.focus();
saveProjectNameInput.select();
}
saveProjectError && (saveProjectError.textContent = "");
saveProjectBackdrop?.classList.remove("hidden");
saveProjectModal?.classList.remove("hidden");
if (saveProjectConfirm) {
saveProjectConfirm.disabled = !(saveProjectNameInput?.value.trim());
}
if (saveProjectDuplicate) {
saveProjectDuplicate.disabled = !(saveProjectNameInput?.value.trim());
}
};
const closeDeleteModal = () => {
deleteProjectBackdrop?.classList.add("hidden");
deleteProjectModal?.classList.add("hidden");
if (deleteProjectError) deleteProjectError.textContent = "";
if (deleteProjectConfirm) {
deleteProjectConfirm.disabled = false;
deleteProjectConfirm.textContent = "Удалить";
}
};
const openDeleteModal = () => {
if (isGuestMode()) return;
if (!currentProjectMeta && lastAttemptedProjectId) {
const meta = findProjectMetaById(lastAttemptedProjectId) || { id: lastAttemptedProjectId, name: "", type: "project" };
currentProjectMeta = meta;
}
if (!currentProjectMeta) return;
deleteProjectBackdrop?.classList.remove("hidden");
deleteProjectModal?.classList.remove("hidden");
if (deleteProjectError) deleteProjectError.textContent = "";
};
const submitDeleteProject = async () => {
if (isGuestMode()) return;
const targetId = currentProjectMeta?.id || lastAttemptedProjectId;
if (!targetId) return;
if (deleteProjectError) deleteProjectError.textContent = "";
if (deleteProjectConfirm) {
deleteProjectConfirm.disabled = true;
deleteProjectConfirm.textContent = "Удаление...";
}
try {
await fetchJSON(`${API_BASE}/${targetId}`, { method: "DELETE" });
await loadProjectsList().catch(() => {});
destroyAllModels();
currentProjectMeta = null;
lastAttemptedProjectId = null;
setStatus("Проект удалён");
if (projectNameInput) {
projectNameInput.value = "";
projectNameInput.disabled = true;
}
closeDeleteModal();
updatePanelsVisibility();
templatesMenu?.hide();
if (deleteProjectButton) deleteProjectButton.disabled = true;
} catch (err) {
console.error(err);
if (deleteProjectError) deleteProjectError.textContent = err.message || "Не удалось удалить проект";
} finally {
if (deleteProjectConfirm) {
deleteProjectConfirm.disabled = false;
deleteProjectConfirm.textContent = "Удалить";
}
}
};
const submitSaveProject = async (mode = "update") => {
if (isGuestMode()) return;
if (!saveProjectNameInput) return;
const trimmedName = saveProjectNameInput.value.trim();
if (!trimmedName) {
if (saveProjectError) saveProjectError.textContent = "Имя проекта обязательно";
if (saveProjectConfirm) saveProjectConfirm.disabled = true;
return;
}
if (saveProjectError) saveProjectError.textContent = "";
const targetId = currentProjectMeta?.id || lastAttemptedProjectId || null;
const targetType = currentProjectMeta?.type || "project";
const effectiveMode = (mode === "new") ? "new" : (targetId ? "update" : "new");
const isUpdate = effectiveMode === "update" && !!targetId;
if (saveProjectConfirm) {
saveProjectConfirm.disabled = true;
saveProjectConfirm.textContent = isUpdate ? "Сохранение..." : "Сохранение...";
}
if (saveProjectDuplicate) {
saveProjectDuplicate.disabled = true;
saveProjectDuplicate.textContent = mode === "new" ? "Сохранение..." : "Сохранить как новый";
}
if (saveProjectHint) {
saveProjectHint.textContent = "Сохраняем проект...";
}
try {
const payload = await buildProjectSnapshot(trimmedName);
payload.type = targetType || payload.type || "project";
if (isUpdate && targetId) {
payload.id = targetId;
} else {
delete payload.id;
}
const saved = await fetchJSON(isUpdate ? `${API_BASE}/${targetId}` : API_BASE, {
method: isUpdate ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
currentProjectMeta = { id: saved.id, name: saved.name, type: saved.type };
lastAttemptedProjectId = currentProjectMeta.id;
await loadProjectsList().catch(() => {});
closeSaveModal();
setStatus(`Проект "${saved.name}" ${isUpdate ? "обновлён" : "сохранён"}`);
if (deleteProjectButton) deleteProjectButton.disabled = !currentProjectMeta;
if (projectNameInput) {
projectNameInput.disabled = false;
projectNameInput.value = saved.name || "";
}
} catch (err) {
console.error(err);
if (saveProjectError) saveProjectError.textContent = err.message || "Не удалось сохранить проект";
} finally {
if (saveProjectConfirm) {
saveProjectConfirm.disabled = false;
saveProjectConfirm.textContent = "Сохранить";
}
if (saveProjectDuplicate) {
saveProjectDuplicate.disabled = false;
saveProjectDuplicate.textContent = "Сохранить как новый";
}
if (saveProjectHint) {
saveProjectHint.textContent = "Введите имя, чтобы сохранить проект в хранилище";
}
}
};
// Меню шаблонов / проектов (после объявления loadModel)
templatesMenu = createTemplatesMenu({
panel: templatesPanel,
fetchProjects: () => loadProjectsList(),
openProject: (projectId) => loadProjectFromServer(projectId),
onShow: () => {
setToolbarButtonActive(templatesButton, true);
setToolbarButtonActive(settingsButton, false);
},
onHide: () => {
setToolbarButtonActive(templatesButton, false);
}
});
settingsMenu = createSettingsMenu({
panel: settingsPanel,
controls: {
toggleEdges,
toggleTransparent,
addMode,
},
onChange: ({ key }) => {
if (key === "cubeText" || key === "cubeEdges" || (key && key.startsWith("navCube"))) {
rebuildNavCube(sdk);
}
if (key === "highlight") {
applyHighlightTheme();
}
if (key && key.startsWith("measure")) {
applyMeasureTheme();
}
if (key && key.startsWith("section")) {
applySectionPlaneTheme();
}
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();
applySectionPlaneTheme();
if (displayModeControl && displayModeSelectHost) {
displayModeSelect = createGlassSelect({
host: displayModeSelectHost,
value: activeDisplayMode,
options: DISPLAY_MODE_OPTIONS,
ariaLabel: "Режим отображения",
onChange: (value) => {
activeDisplayMode = value;
applyDisplayMode();
focusCanvasAndControls();
},
});
setDisplayModeControlState();
}
if (lightingModeControl && lightingModeSelectHost) {
lightingModeSelect = createGlassSelect({
host: lightingModeSelectHost,
value: activeLightingMode,
options: LIGHTING_MODE_OPTIONS,
ariaLabel: "Свет",
onChange: (value) => {
activeLightingMode = value;
applyLightingMode({ announce: true });
focusCanvasAndControls();
},
});
setLightingModeControlState();
}
if (navigationAxisControl && navigationAxisSelectHost) {
navigationAxisSelect = createGlassSelect({
host: navigationAxisSelectHost,
value: activeNavigationAxis,
options: NAVIGATION_AXIS_OPTIONS,
ariaLabel: "Ось навигации",
onChange: (value) => {
activeNavigationAxis = value;
applyNavigationAxis({ resetAuto: true, announce: true });
focusCanvasAndControls();
},
});
setNavigationAxisControlState();
}
if (zoomSpeedControl) {
zoomSpeedSlider = createSliderControl({
label: "Скорость зума",
value: activeZoomSpeed,
min: 5,
max: 100,
step: 1,
className: "inspector-slider",
showValue: true,
formatValue: (value) => `${Math.round(value)}%`,
onChange: (value) => {
setZoomSpeed(value);
},
});
zoomSpeedControl.replaceChildren(zoomSpeedSlider);
}
inspectorCollapseButton?.addEventListener("click", () => setInspectorCollapsed(true));
inspectorRestoreButton?.addEventListener("click", () => setInspectorCollapsed(false));
if (!isGuestMode()) {
loadProjectsList().catch(() => {
templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" });
});
}
const handleFile = (file) => {
if (isGuestMode()) {
setStatus("Гостевой просмотр не принимает загрузку файлов");
return;
}
const detected = guessTypeFromName(file.name);
if (!detected) {
showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl");
return;
}
log(`Файл выбран: ${file.name} (${detected})`, false);
const urlKey = `memory:${crypto.randomUUID ? crypto.randomUUID() : Date.now()}`;
file.arrayBuffer().then((buf) => {
// Храним копию буфера, чтобы загрузчик не детачил исходный
const bytes = cloneToUint8(buf) || new Uint8Array(buf.byteLength || 0);
memoryStore.set(urlKey, bytes);
loadModel({ type: detected, url: urlKey, name: file.name, replace: !addMode.checked });
}).catch((err) => {
console.error(err);
showError(err.message || err);
});
};
if (chooseFileBtn) {
chooseFileBtn.addEventListener("click", () => fileInput?.click());
}
if (fileInput) {
fileInput.addEventListener("change", (event) => {
const file = event.target.files?.[0];
if (file) handleFile(file);
fileInput.value = "";
});
}
if (viewerWrapper) {
["dragenter", "dragover"].forEach((evt) => {
viewerWrapper.addEventListener(evt, (e) => {
if (isGuestMode()) return;
e.preventDefault();
e.stopPropagation();
dropZone.classList.add("active");
});
});
["dragleave", "drop"].forEach((evt) => {
viewerWrapper.addEventListener(evt, (e) => {
if (isGuestMode()) return;
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove("active");
});
});
viewerWrapper.addEventListener("drop", (e) => {
if (isGuestMode()) return;
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer?.files?.[0];
if (file) {
handleFile(file);
}
});
}
fitViewBtn.addEventListener("click", () => {
viewer.cameraFlight.flyTo({ aabb: viewer.scene.getAABB({}) });
});
toggleEdges.addEventListener("change", async () => {
if (suppressEdgeReload) return;
if (!activeModels.length) return;
setStatus("Перезагрузка с новыми рёбрами...");
try {
const snapshot = await buildProjectSnapshot(currentProjectMeta?.name || "Текущий проект");
snapshot.id = currentProjectMeta?.id || "local_reload";
snapshot.type = currentProjectMeta?.type || "project";
await loadProjectSnapshot(snapshot);
} catch (err) {
console.error(err);
showError(err.message || "Не удалось перезагрузить сцену");
}
});
toggleTransparent.addEventListener("change", () => {
viewer.transparent = toggleTransparent.checked;
if (viewer.scene?.canvas) {
viewer.scene.canvas.transparent = toggleTransparent.checked;
}
});
homeButton.addEventListener("click", () => {
destroyAllModels();
dropZone.classList.remove("hidden");
setStatus("Готов к загрузке");
modelCounter = 0;
setLoading(false);
memoryStore.clear();
currentSelection = null;
updateTransformInputs(null);
templatesMenu?.hide();
setMeasureActive(false);
clearMeasurements();
closeSaveModal();
closeDeleteModal();
// Примеры доступны, но сцена чистая
gizmo?.clearTarget();
updateNavCubeVisibility();
updatePanelsVisibility();
});
if (saveProjectButton) {
saveProjectButton.addEventListener("click", openSaveModal);
}
if (saveModelSettingsButton) {
saveModelSettingsButton.addEventListener("click", saveCurrentModelSettings);
}
saveProjectCancel?.addEventListener("click", closeSaveModal);
saveProjectClose?.addEventListener("click", closeSaveModal);
saveProjectConfirm?.addEventListener("click", submitSaveProject);
saveProjectDuplicate?.addEventListener("click", () => submitSaveProject("new"));
saveProjectBackdrop?.addEventListener("click", (e) => {
if (e.target === saveProjectBackdrop) {
closeSaveModal();
}
});
saveProjectNameInput?.addEventListener("input", () => {
saveProjectError && (saveProjectError.textContent = "");
if (saveProjectConfirm) {
saveProjectConfirm.disabled = !saveProjectNameInput.value.trim();
}
if (saveProjectDuplicate) {
saveProjectDuplicate.disabled = !saveProjectNameInput.value.trim();
}
});
saveProjectNameInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
submitSaveProject();
}
});
projectNameInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
submitProjectRename();
}
});
projectNameInput?.addEventListener("blur", () => {
submitProjectRename();
});
if (deleteProjectButton) {
deleteProjectButton.addEventListener("click", openDeleteModal);
}
deleteProjectCancel?.addEventListener("click", closeDeleteModal);
deleteProjectClose?.addEventListener("click", closeDeleteModal);
deleteProjectConfirm?.addEventListener("click", submitDeleteProject);
deleteProjectBackdrop?.addEventListener("click", (e) => {
if (e.target === deleteProjectBackdrop) {
closeDeleteModal();
}
});
shareModalCancel?.addEventListener("click", closeShareModal);
shareModalClose?.addEventListener("click", closeShareModal);
shareCopyButton?.addEventListener("click", copyShareLink);
shareModalBackdrop?.addEventListener("click", (e) => {
if (e.target === shareModalBackdrop) {
closeShareModal();
}
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (e.target && ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return;
if (shareModal && !shareModal.classList.contains("hidden")) {
closeShareModal();
e.preventDefault();
e.stopPropagation();
return;
}
if (deleteProjectModal && !deleteProjectModal.classList.contains("hidden")) {
closeDeleteModal();
e.preventDefault();
e.stopPropagation();
return;
}
if (saveProjectModal && !saveProjectModal.classList.contains("hidden")) {
closeSaveModal();
e.preventDefault();
e.stopPropagation();
return;
}
if (measureActive && measureControl?.currentMeasurement) {
measureControl.reset();
e.preventDefault();
e.stopPropagation();
}
});
// Горячие клавиши W/E/R для переключения режимов гизмы
document.addEventListener("keydown", (e) => {
if (!gizmo) return;
if (e.target && ["INPUT", "TEXTAREA"].includes(e.target.tagName)) return;
if (e.key === "w" || e.key === "W") gizmo.setMode("translate");
if (e.key === "e" || e.key === "E") gizmo.setMode("rotate");
if (e.key === "r" || e.key === "R") gizmo.setMode("scale");
});
modeButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const mode = btn.dataset.mode;
gizmo?.setMode(mode);
});
});
const bindInput = (key, transformKey) => {
const input = transformInputs[key];
if (!input) return;
input.addEventListener("focus", () => {
const fallbackModel =
selectionState?.modelId ||
gizmo?.target?.modelId ||
lastSelection?.modelId;
const fallbackObject =
selectionState?.ids?.[0] ||
gizmo?.target?.objectId ||
lastSelection?.ids?.[0] ||
"__model__";
if (fallbackModel) {
activeTransformTarget = { modelId: fallbackModel, objectId: fallbackObject || "__model__" };
}
});
const handler = (e) => {
const modelId =
selectionState?.modelId ||
gizmo?.target?.modelId ||
activeTransformTarget?.modelId ||
lastSelection?.modelId ||
null;
const objectId =
selectionState?.ids?.[0] ||
gizmo?.target?.objectId ||
activeTransformTarget?.objectId ||
lastSelection?.ids?.[0] ||
"__model__";
if (!modelId) return;
const model =
(sceneModels[modelId] || viewer?.scene?.models?.[modelId]) ||
null;
if (!model) return;
const entity =
objectId && objectId !== "__model__"
? resolveEntity(objectId, modelId) || model
: model;
const resolvedEntity = entity || model;
const modelTransforms = (modelId && transformStore[modelId]) || {};
const isInputEvent = e?.type === "input";
const beforeVal = input.value || "";
// Разрешаем свободное редактирование: пока в поле пусто или просто "-", не применяем и не перетираем значение
let rawVal = beforeVal.trim();
if (rawVal === "" || rawVal === "-") {
return;
}
const value = parseInputNumber(rawVal);
if (!Number.isFinite(value)) {
return;
}
// приоритет: значения из инпутов -> сохранённые -> текущие в сцене -> дефолты
const uiPos = [
parseInputNumber(transformInputs.posX?.value),
parseInputNumber(transformInputs.posY?.value),
parseInputNumber(transformInputs.posZ?.value),
];
const uiRot = [
parseInputNumber(transformInputs.rotX?.value),
parseInputNumber(transformInputs.rotY?.value),
parseInputNumber(transformInputs.rotZ?.value),
];
const uiScale = [
parseInputNumber(transformInputs.scaleX?.value),
parseInputNumber(transformInputs.scaleY?.value),
parseInputNumber(transformInputs.scaleZ?.value),
];
const stored = modelTransforms[objectId] || modelTransforms["__model__"] || {};
const basePos = stored.position
? [...stored.position]
: resolvedEntity?.position
? [...resolvedEntity.position]
: [0, 0, 0];
const baseRot = stored.rotation
? [...stored.rotation]
: resolvedEntity?.rotation
? [...resolvedEntity.rotation]
: [0, 0, 0];
const baseScale = stored.scale
? [...stored.scale]
: resolvedEntity?.scale
? [...resolvedEntity.scale]
: [1, 1, 1];
const next = {
position: [
Number.isFinite(uiPos[0]) ? uiPos[0] : basePos[0] || 0,
Number.isFinite(uiPos[1]) ? uiPos[1] : basePos[1] || 0,
Number.isFinite(uiPos[2]) ? uiPos[2] : basePos[2] || 0,
],
rotation: [
Number.isFinite(uiRot[0]) ? uiRot[0] : baseRot[0] || 0,
Number.isFinite(uiRot[1]) ? uiRot[1] : baseRot[1] || 0,
Number.isFinite(uiRot[2]) ? uiRot[2] : baseRot[2] || 0,
],
scale: [
Number.isFinite(uiScale[0]) ? uiScale[0] : baseScale[0] || 1,
Number.isFinite(uiScale[1]) ? uiScale[1] : baseScale[1] || 1,
Number.isFinite(uiScale[2]) ? uiScale[2] : baseScale[2] || 1,
],
};
if (transformKey === "position") {
const axis = key.slice(-1).toLowerCase();
const idx = axis === "x" ? 0 : axis === "y" ? 1 : 2;
next.position[idx] = value;
} else if (transformKey === "rotation") {
const axis = key.slice(-1).toLowerCase();
const idx = axis === "x" ? 0 : axis === "y" ? 1 : 2;
next.rotation[idx] = value;
} else if (transformKey === "scale") {
const axis = key.slice(-1).toLowerCase();
const idx = axis === "x" ? 0 : axis === "y" ? 1 : 2;
next.scale[idx] = value || 1;
}
if (selectionState) {
applyGroupTransform(next);
} else {
resolvedEntity.position = [...next.position];
resolvedEntity.rotation = [...next.rotation];
resolvedEntity.scale = [...next.scale];
const key =
resolvedEntity === model || !objectId ? "__model__" : objectId;
persistTransform(modelId, key, {
position: [...resolvedEntity.position],
rotation: [...resolvedEntity.rotation],
scale: [...resolvedEntity.scale],
});
activeTransformTarget = { modelId, objectId: key };
if (!isInputEvent) {
updateTransformInputs(next);
}
viewer.scene.setAABBDirty?.();
viewer.scene.glRedraw?.();
}
const refreshTarget = selectionState
? { modelId: modelId, objectId: objectId || "__model__" }
: (activeTransformTarget || gizmo?.target);
if (refreshTarget?.modelId) {
gizmo?.setTarget(refreshTarget);
}
};
input.addEventListener("change", handler);
input.addEventListener("blur", handler);
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
handler(ev);
input.blur();
}
});
};
bindInput("posX", "position");
bindInput("posY", "position");
bindInput("posZ", "position");
bindInput("rotX", "rotation");
bindInput("rotY", "rotation");
bindInput("rotZ", "rotation");
bindInput("scaleX", "scale");
bindInput("scaleY", "scale");
bindInput("scaleZ", "scale");
// Переустановим pivot камеры на центр текущего выделения при двойном клике в инспекторе, если не переданы ids
if (treeContainer) {
treeContainer.addEventListener("dblclick", () => {
if (selectionState?.pivotCenter && viewer?.cameraControl) {
viewer.cameraControl.pivotPos = selectionState.pivotCenter;
}
});
}
if (objectModal) {
objectContextModal = createObjectModal({
panel: objectModal,
backdrop: objectModalBackdrop,
onDelete: (target) => {
if (!target?.modelId) return;
deleteModelById(target.modelId);
objectContextModal?.hide?.();
},
onClose: () => {}
});
}
void startupModelLoadPromise;
};
document.addEventListener("DOMContentLoaded", () => {
initViewer().catch((err) => {
reportViewerEvent("init-viewer-error", {
message: err?.message || String(err),
stack: err?.stack,
}, "error");
showError(err?.message || "Не удалось инициализировать viewer");
});
});
// нижняя тулбар
if (bottomToolbar) {
bottomToolbar.addEventListener("click", (e) => {
const btn = e.target.closest(".tb-btn");
if (!btn) return;
if (isGuestMode()) return;
const action = btn.dataset.action;
if (action === "settings") {
if (btn.classList.contains("active")) {
closeSettingsPanel();
} else {
openSettingsPanel();
}
} else if (action === "templates") {
if (btn.classList.contains("active")) {
closeTemplatesPanel();
} else {
openTemplatesPanel();
}
} else if (action === "measure") {
if (!measurePlugin || !measureControl) return;
setMeasureActive(!measureActive);
} else if (action === "section") {
toggleSectionPlanes();
} else if (action === "projection") {
toggleProjection();
} else if (action === "share") {
openShareModal();
} else if (action === "add") {
fileInput.click();
}
});
}
sectionToolbar?.addEventListener("click", (e) => {
if (isGuestMode()) return;
const btn = e.target.closest("[data-section-action]");
if (!btn) return;
const action = btn.dataset.sectionAction;
if (action === "camera" || action === "x" || action === "y" || action === "z") {
createOrUpdateSectionPlane(action);
} else if (action === "flip") {
flipSectionPlanes();
} else if (action === "clear") {
clearSectionPlanes();
setStatus("Разрез выключен");
}
});
function updateTransformInputs(transform) {
const empty = {
position: ["", "", ""],
rotation: ["", "", ""],
scale: ["", "", ""],
};
const t = transform || empty;
const pos = t.position || ["", "", ""];
const rot = t.rotation || ["", "", ""];
const scale = t.scale || ["", "", ""];
transformInputs.posX.value = pos[0];
transformInputs.posY.value = pos[1];
transformInputs.posZ.value = pos[2];
transformInputs.rotX.value = rot[0];
transformInputs.rotY.value = rot[1];
transformInputs.rotZ.value = rot[2];
transformInputs.scaleX.value = scale[0];
transformInputs.scaleY.value = scale[1];
transformInputs.scaleZ.value = scale[2];
}