7389 lines
258 KiB
JavaScript
7389 lines
258 KiB
JavaScript
const SDK_VERSION = "2.6.78"; // соответствует package.json
|
||
const SDK_URL = "./lib/dc-camera-context.es.js?v=3";
|
||
|
||
import { createLogo } from "./src/ui/logo.js";
|
||
import { applyToolbarVars } from "./src/ui/toolbar.js?v=3";
|
||
import { createTemplatesMenu } from "./src/ui/templatesMenu.js?v=3";
|
||
import { createSettingsMenu } from "./src/ui/settingsMenu.js?v=15";
|
||
import { createMeasurementModal } from "./src/ui/measurementModal.js";
|
||
import { createGlassSelect } from "./src/ui/glassSelect.js";
|
||
import { createColorControl } from "./src/ui/controls/color.js";
|
||
import { createSliderControl } from "./src/ui/controls/slider.js";
|
||
import "./theme.js?v=3";
|
||
import { TransformGizmo } from "./transformGizmo.js";
|
||
import { createObjectModal } from "./src/ui/objectModal.js";
|
||
|
||
const dropZone = document.getElementById("dropZone");
|
||
const viewerWrapper = document.querySelector(".viewer-wrapper");
|
||
const headerEl = document.querySelector("header");
|
||
const sidePanelEl = document.querySelector(".side-panel");
|
||
const inspectorCollapseButton = document.getElementById("inspectorCollapseButton");
|
||
const inspectorRestoreTray = document.getElementById("inspectorRestoreTray");
|
||
const inspectorRestoreButton = document.getElementById("inspectorRestoreButton");
|
||
const fileInput = document.getElementById("fileInput");
|
||
const chooseFileBtn = document.getElementById("chooseFile");
|
||
const examplesBar = document.getElementById("examplesBar");
|
||
const templatesPanel = document.getElementById("templatesPanel");
|
||
const settingsPanel = document.getElementById("settingsPanel");
|
||
const measureModalBackdrop = document.getElementById("measureModalBackdrop");
|
||
const measureModal = document.getElementById("measureModal");
|
||
const objectModalBackdrop = document.getElementById("objectModalBackdrop");
|
||
const objectModal = document.getElementById("objectModal");
|
||
const statusText = document.getElementById("statusText");
|
||
const fitViewBtn = document.getElementById("fitView");
|
||
const toggleEdges = document.getElementById("toggleEdges");
|
||
const toggleTransparent = document.getElementById("toggleTransparent");
|
||
const addMode = document.getElementById("addMode");
|
||
const homeButton = document.getElementById("homeButton");
|
||
const bottomToolbar = document.getElementById("bottomToolbar");
|
||
const measureButton = document.querySelector('[data-action="measure"]');
|
||
const settingsButton = document.querySelector('[data-action="settings"]');
|
||
const templatesButton = document.querySelector('[data-action="templates"]');
|
||
const shareButton = document.querySelector('[data-action="share"]');
|
||
const sectionButton = document.querySelector('[data-action="section"]');
|
||
const commentsButton = document.querySelector('[data-action="comments"]');
|
||
const sectionToolbar = document.getElementById("sectionToolbar");
|
||
const sectionOverviewCanvas = document.getElementById("sectionPlanesOverview");
|
||
const projectionToggle = document.querySelector('[data-action="projection"]');
|
||
const commentsPanel = document.getElementById("commentsPanel");
|
||
const commentsPanelHeader = document.getElementById("commentsPanelHeader");
|
||
const commentsCollapseButton = document.getElementById("commentsCollapseButton");
|
||
const commentsRestoreTray = document.getElementById("commentsRestoreTray");
|
||
const commentsRestoreButton = document.getElementById("commentsRestoreButton");
|
||
const commentsList = document.getElementById("commentsList");
|
||
const commentTargetsLayer = document.getElementById("commentTargetsLayer");
|
||
const commentContextMenu = document.getElementById("commentContextMenu");
|
||
const commentModalBackdrop = document.getElementById("commentModalBackdrop");
|
||
const commentModal = document.getElementById("commentModal");
|
||
const commentModalHeader = document.getElementById("commentModalHeader");
|
||
const commentModalClose = document.getElementById("commentModalClose");
|
||
const commentModalExpandButton = document.getElementById("commentModalExpandButton");
|
||
const commentModalSubtitle = document.getElementById("commentModalSubtitle");
|
||
const commentModalEditedMeta = document.getElementById("commentModalEditedMeta");
|
||
const commentTitleInput = document.getElementById("commentTitleInput");
|
||
const commentBodyInput = document.getElementById("commentBodyInput");
|
||
const commentDropzone = document.getElementById("commentDropzone");
|
||
const commentAttachButton = document.getElementById("commentAttachButton");
|
||
const commentAttachmentInput = document.getElementById("commentAttachmentInput");
|
||
const commentAttachmentGrid = document.getElementById("commentAttachmentGrid");
|
||
const commentSubitemsSection = document.getElementById("commentSubitemsSection");
|
||
const commentSubitemsList = document.getElementById("commentSubitemsList");
|
||
const commentAddSubitemButton = document.getElementById("commentAddSubitemButton");
|
||
const commentSubitemMenu = document.getElementById("commentSubitemMenu");
|
||
const commentThreadList = document.getElementById("commentThreadList");
|
||
const commentReplyInput = document.getElementById("commentReplyInput");
|
||
const commentReplyAttachButton = document.getElementById("commentReplyAttachButton");
|
||
const commentReplyAttachmentInput = document.getElementById("commentReplyAttachmentInput");
|
||
|
||
const DEFAULT_LAUNCHER_URL = "https://hub.nodedc.ru/";
|
||
|
||
const getLauncherUrl = () => {
|
||
const configured = typeof window !== "undefined" ? window.__BIMDC_LAUNCHER_URL__ : "";
|
||
const raw = typeof configured === "string" && configured.trim()
|
||
? configured.trim()
|
||
: DEFAULT_LAUNCHER_URL;
|
||
try {
|
||
return new URL(raw, window.location.href).toString();
|
||
} catch {
|
||
return DEFAULT_LAUNCHER_URL;
|
||
}
|
||
};
|
||
const commentReplyAttachmentGrid = document.getElementById("commentReplyAttachmentGrid");
|
||
const commentModalError = document.getElementById("commentModalError");
|
||
const commentCancelButton = document.getElementById("commentCancelButton");
|
||
const commentApplyButton = document.getElementById("commentApplyButton");
|
||
const commentSaveButton = document.getElementById("commentSaveButton");
|
||
const commentApplyInlineButton = document.getElementById("commentApplyInlineButton");
|
||
const treeContainer = document.getElementById("treeContainer");
|
||
const displayModeControl = document.getElementById("displayModeControl");
|
||
const displayModeSelectHost = document.getElementById("displayModeSelect");
|
||
const customDisplayColorRow = document.getElementById("customDisplayColorRow");
|
||
const customDisplayColorControlHost = document.getElementById("customDisplayColorControl");
|
||
const customDisplaySaturationControl = document.getElementById("customDisplaySaturationControl");
|
||
const lightingModeControl = document.getElementById("lightingModeControl");
|
||
const lightingModeSelectHost = document.getElementById("lightingModeSelect");
|
||
const modelVersionControl = document.getElementById("modelVersionControl");
|
||
const modelVersionSelectHost = document.getElementById("modelVersionSelect");
|
||
const modelVersionHistoryButton = document.getElementById("modelVersionHistoryButton");
|
||
const modelVersionUploadButton = document.getElementById("modelVersionUploadButton");
|
||
const modelVersionFileInput = document.getElementById("modelVersionFileInput");
|
||
const navigationAxisControl = document.getElementById("navigationAxisControl");
|
||
const navigationAxisSelectHost = document.getElementById("navigationAxisSelect");
|
||
const zoomSpeedControl = document.getElementById("zoomSpeedControl");
|
||
const logList = document.getElementById("logList");
|
||
const loaderEl = document.getElementById("loader");
|
||
const navCubeCanvas = document.getElementById("navCube");
|
||
const mainCanvas = document.getElementById("viewerCanvas");
|
||
const saveProjectSection = document.getElementById("saveProjectSection");
|
||
const saveProjectButton = document.getElementById("saveProjectButton");
|
||
const saveModelSettingsButton = document.getElementById("saveModelSettingsButton");
|
||
const saveProjectBackdrop = document.getElementById("saveProjectBackdrop");
|
||
const saveProjectModal = document.getElementById("saveProjectModal");
|
||
const saveProjectClose = document.getElementById("saveProjectClose");
|
||
const saveProjectCancel = document.getElementById("saveProjectCancel");
|
||
const saveProjectConfirm = document.getElementById("saveProjectConfirm");
|
||
const saveProjectDuplicate = document.getElementById("saveProjectDuplicate");
|
||
const saveProjectNameInput = document.getElementById("saveProjectName");
|
||
const saveProjectError = document.getElementById("saveProjectError");
|
||
const saveProjectHint = document.getElementById("saveProjectHint");
|
||
const deleteProjectButton = document.getElementById("deleteProjectButton");
|
||
const deleteProjectBackdrop = document.getElementById("deleteProjectBackdrop");
|
||
const deleteProjectModal = document.getElementById("deleteProjectModal");
|
||
const deleteProjectClose = document.getElementById("deleteProjectClose");
|
||
const deleteProjectCancel = document.getElementById("deleteProjectCancel");
|
||
const deleteProjectConfirm = document.getElementById("deleteProjectConfirm");
|
||
const deleteProjectError = document.getElementById("deleteProjectError");
|
||
const shareModalBackdrop = document.getElementById("shareModalBackdrop");
|
||
const shareModal = document.getElementById("shareModal");
|
||
const shareModalClose = document.getElementById("shareModalClose");
|
||
const shareModalCancel = document.getElementById("shareModalCancel");
|
||
const shareLinkInput = document.getElementById("shareLinkInput");
|
||
const shareCopyButton = document.getElementById("shareCopyButton");
|
||
const shareError = document.getElementById("shareError");
|
||
const projectNameSection = document.getElementById("projectNameSection");
|
||
const projectNameInput = document.getElementById("projectNameInput");
|
||
const projectNameError = document.getElementById("projectNameError");
|
||
const transformInputs = {
|
||
posX: document.getElementById("posX"),
|
||
posY: document.getElementById("posY"),
|
||
posZ: document.getElementById("posZ"),
|
||
rotX: document.getElementById("rotX"),
|
||
rotY: document.getElementById("rotY"),
|
||
rotZ: document.getElementById("rotZ"),
|
||
scaleX: document.getElementById("scaleX"),
|
||
scaleY: document.getElementById("scaleY"),
|
||
scaleZ: document.getElementById("scaleZ"),
|
||
};
|
||
const globalInputs = {
|
||
x: document.getElementById("globalX"),
|
||
y: document.getElementById("globalY"),
|
||
z: document.getElementById("globalZ"),
|
||
};
|
||
const modeButtons = document.querySelectorAll("[data-mode]");
|
||
|
||
const PANEL_OFFSETS = {
|
||
templates: { left: 52, bottom: 12 },
|
||
settings: { left: 52, bottom: 12 },
|
||
measurement: { left: 52, bottom: 12 },
|
||
};
|
||
|
||
const applyPanelOffsets = () => {
|
||
const root = document.documentElement?.style;
|
||
if (!root) return;
|
||
root.setProperty("--templates-left", `${PANEL_OFFSETS.templates.left}px`);
|
||
root.setProperty("--templates-bottom", `${PANEL_OFFSETS.templates.bottom}px`);
|
||
root.setProperty("--settings-left", `${PANEL_OFFSETS.settings.left}px`);
|
||
root.setProperty("--settings-bottom", `${PANEL_OFFSETS.settings.bottom}px`);
|
||
root.setProperty("--measure-left", `${PANEL_OFFSETS.measurement.left}px`);
|
||
root.setProperty("--measure-bottom", `${PANEL_OFFSETS.measurement.bottom}px`);
|
||
};
|
||
|
||
const API_BASE = "/api/projects";
|
||
const MODEL_SETTINGS_API = "/api/model-settings";
|
||
const AUTH_SESSION_API = "/api/auth/session";
|
||
const SHARES_API = "/api/shares";
|
||
const COMMENTS_API = "/api/comments";
|
||
const UPLOAD_VERSIONS_API = "/api/uploads/versions";
|
||
const SHARE_AUTH_GUEST_PARAM = "ndc_bim_guest";
|
||
const SHARE_PROMOTION_TIMEOUT_MS = 4500;
|
||
const SHARE_PROMOTION_RETRY_MS = 15000;
|
||
const SESSION_STATUS_WATCH_INTERVAL_MS = 2500;
|
||
const NODEDC_SESSION_LOGOUT_EVENT_TYPE = "nodedc:session:logout";
|
||
const NODEDC_SESSION_LOGIN_EVENT_TYPE = "nodedc:session:login";
|
||
|
||
const acceptByType = {
|
||
xkt: ".xkt",
|
||
gltf: ".glb,.gltf",
|
||
glb: ".glb,.gltf",
|
||
bim: ".bim",
|
||
las: ".las,.laz",
|
||
laz: ".las,.laz",
|
||
obj: ".obj",
|
||
stl: ".stl",
|
||
};
|
||
|
||
const DEFAULT_EXAMPLES = [
|
||
{
|
||
id: "ifc-xkt",
|
||
name: "IFC / XKT пример",
|
||
description: "Стандартная BIM-модель Schependomlaan.",
|
||
type: "xkt",
|
||
url: "./examples/ifc-xkt/Schependomlaan.ifc.xkt",
|
||
},
|
||
{
|
||
id: "glb",
|
||
name: "GLB пример",
|
||
description: "Городской фрагмент MAP в формате glTF/GLB.",
|
||
type: "gltf",
|
||
url: "./examples/glb/MAP.glb",
|
||
},
|
||
{
|
||
id: "pointcloud",
|
||
name: "Point Cloud пример",
|
||
description: "Облако точек Nalls Pumpkin Hill (LAZ).",
|
||
type: "las",
|
||
url: "./examples/pointcloud/Nalls_Pumpkin_Hill.laz",
|
||
},
|
||
{
|
||
id: "bim",
|
||
name: "BIM (.bim) пример",
|
||
description: "Тестовая BIM-модель BlenderHouse в формате .bim.",
|
||
type: "bim",
|
||
url: "./examples/bim/BlenderHouse.bim",
|
||
},
|
||
];
|
||
|
||
const templateFallbacks = DEFAULT_EXAMPLES.map((ex) => ({
|
||
id: ex.id,
|
||
name: ex.name,
|
||
type: "template",
|
||
updatedAt: null
|
||
}));
|
||
|
||
const DISPLAY_MODE_OPTIONS = [
|
||
{ value: "source", label: "Исходный" },
|
||
{ value: "clay", label: "Clay" },
|
||
{ value: "white", label: "Белый" },
|
||
{ value: "custom", label: "Кастом" },
|
||
{ value: "edges", label: "Рёбра" },
|
||
{ value: "xray", label: "Рентген" },
|
||
];
|
||
|
||
const DEFAULT_CUSTOM_DISPLAY_COLOR = "#ffffff";
|
||
const DEFAULT_CUSTOM_DISPLAY_SATURATION = 100;
|
||
|
||
const NAVIGATION_AXIS_OPTIONS = [
|
||
{ value: "auto", label: "Авто" },
|
||
{ value: "x-up", label: "X" },
|
||
{ value: "y-up", label: "Y" },
|
||
{ value: "z-up", label: "Z" },
|
||
];
|
||
|
||
const LIGHTING_MODE_OPTIONS = [
|
||
{ value: "standard", label: "Стандарт" },
|
||
{ value: "soft", label: "Мягкий" },
|
||
{ value: "technical", label: "Тех." },
|
||
];
|
||
|
||
const NAVIGATION_AXIS_PRESETS = {
|
||
"y-up": {
|
||
worldAxis: [1, 0, 0, 0, 1, 0, 0, 0, 1],
|
||
up: [0, 1, 0],
|
||
forward: [0, 0, 1],
|
||
},
|
||
"z-up": {
|
||
worldAxis: [1, 0, 0, 0, 0, 1, 0, -1, 0],
|
||
up: [0, 0, 1],
|
||
forward: [0, -1, 0],
|
||
},
|
||
"x-up": {
|
||
worldAxis: [0, 1, 0, 1, 0, 0, 0, 0, -1],
|
||
up: [1, 0, 0],
|
||
forward: [0, 0, -1],
|
||
},
|
||
};
|
||
|
||
let templatesMenu = null;
|
||
let settingsMenu = null;
|
||
let pendingDesignState = null;
|
||
let measurementModal = null;
|
||
|
||
applyPanelOffsets();
|
||
|
||
let viewer = null;
|
||
let treeView = null;
|
||
let currentBlobUrl = null;
|
||
let modelCounter = 0;
|
||
const activeModels = [];
|
||
let viewerMode = "standard";
|
||
let runtimeSessionInfo = { authRequired: false, authenticated: false, canShare: true, user: null };
|
||
let startupSharePayload = null;
|
||
let modelVersionHistory = null;
|
||
let modelVersionHistoryLoading = false;
|
||
let modelVersionUploading = false;
|
||
let versionHistoryModal = null;
|
||
let emptyWheelNavigationRestoreTimer = null;
|
||
let lastSurfaceDollyFactor = null;
|
||
let lastSurfaceDollyFactorAt = 0;
|
||
const GUEST_TOOLBAR_ACTIONS = new Set(["measure", "section", "projection"]);
|
||
const memoryStore = new Map(); // url -> ArrayBuffer
|
||
const blobUrls = [];
|
||
let gizmo = null;
|
||
const transformStore = {}; // modelId -> objectId -> {position, rotation, scale}
|
||
const modelTypes = {}; // modelId -> normalized type
|
||
const sceneModels = {}; // modelId -> SceneModel
|
||
const pointCloudColorSnapshots = new Map(); // modelId -> [{ layer, colors, workingColors }]
|
||
let activeDisplayMode = "source";
|
||
let activeLightingMode = "technical";
|
||
let activeNavigationAxis = "auto";
|
||
let activeZoomSpeed = 20;
|
||
let activeCustomDisplayColor = DEFAULT_CUSTOM_DISPLAY_COLOR;
|
||
let activeCustomDisplaySaturation = DEFAULT_CUSTOM_DISPLAY_SATURATION;
|
||
let inspectorCollapsed = false;
|
||
let displayModeSelect = null;
|
||
let customDisplayColorControl = null;
|
||
let customDisplaySaturationSlider = null;
|
||
let lightingModeSelect = null;
|
||
let modelVersionSelect = null;
|
||
let navigationAxisSelect = null;
|
||
let zoomSpeedSlider = null;
|
||
let defaultCameraWorldAxis = null;
|
||
let defaultCameraUp = null;
|
||
let currentSelection = null; // { modelId, objectId }
|
||
const customTrees = new Map(); // modelId -> element
|
||
let navCubePlugin = null;
|
||
let measurePlugin = null;
|
||
let measureControl = null;
|
||
let measureActive = false;
|
||
let activeMeasurementId = null;
|
||
let sectionPlanesPlugin = null;
|
||
let activeSectionPlaneId = null;
|
||
let sectionPlaneCounter = 0;
|
||
let sectionModeActive = false;
|
||
let activeSectionAxis = null;
|
||
let commentsModeActive = false;
|
||
let commentsCollapsed = false;
|
||
let commentRecords = [];
|
||
let commentSourcesLoaded = new Set();
|
||
let commentContextTarget = null;
|
||
let commentModalMode = "create";
|
||
let commentModalCommentId = null;
|
||
let commentModalExpanded = false;
|
||
let commentSaveInFlight = false;
|
||
let activeCommentId = null;
|
||
let hoverCommentId = null;
|
||
let commentDraftAttachments = [];
|
||
let commentReplyAttachments = [];
|
||
let commentDraftSubitems = [];
|
||
let commentTargetRaf = 0;
|
||
let commentLongPressTimer = null;
|
||
let commentLongPressStart = null;
|
||
let suppressNextCanvasClick = false;
|
||
let floatingWindowOrder = [];
|
||
const floatingWindowElements = new Map();
|
||
const WORKSPACE_WINDOW_Z_BASE = 30080;
|
||
const WORKSPACE_WINDOW_Z_STEP = 10;
|
||
let objectContextModal = null;
|
||
let mathUtil = null;
|
||
let selectionState = null; // { ids, modelId, pivotCenter, baseTransforms: Map, transform }
|
||
let lastSelection = { ids: [], modelId: null };
|
||
let activeTransformTarget = null; // последняя цель для инпутов трансформаций
|
||
let sdkRef = null;
|
||
let projectIndex = { templates: [], projects: [] };
|
||
let currentProjectMeta = null;
|
||
let isLoadingSavedProject = false;
|
||
const uploadCache = new Map(); // srcKey -> uploaded src
|
||
let suppressEdgeReload = false;
|
||
let lastAttemptedProjectId = null;
|
||
const treeModelIds = new Set();
|
||
|
||
const CAMERA_CONTROL_SETTINGS = {
|
||
keyboardDollyRate: 45,
|
||
dollyInertia: 0,
|
||
dollyMinSpeed: 0.04,
|
||
dollyProximityThreshold: 30,
|
||
};
|
||
|
||
const ZOOM_SPEED_PERCENT_MIN = 5;
|
||
const ZOOM_SPEED_PERCENT_MAX = 100;
|
||
const ZOOM_DOLLY_RATE_MIN = 1;
|
||
const ZOOM_DOLLY_RATE_MAX = 100;
|
||
const ZOOM_DOLLY_RESPONSE_EXPONENT = 1.45;
|
||
const LAST_SURFACE_DOLLY_FACTOR_TTL = 1500;
|
||
|
||
const normalizeZoomSpeed = (value) => {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return 20;
|
||
return Math.min(ZOOM_SPEED_PERCENT_MAX, Math.max(ZOOM_SPEED_PERCENT_MIN, Math.round(numeric)));
|
||
};
|
||
|
||
const getZoomDollyRate = (value) => {
|
||
const normalized = normalizeZoomSpeed(value);
|
||
const range = ZOOM_SPEED_PERCENT_MAX - ZOOM_SPEED_PERCENT_MIN;
|
||
const t = range > 0 ? (normalized - ZOOM_SPEED_PERCENT_MIN) / range : 0;
|
||
return ZOOM_DOLLY_RATE_MIN + (Math.pow(t, ZOOM_DOLLY_RESPONSE_EXPONENT) * (ZOOM_DOLLY_RATE_MAX - ZOOM_DOLLY_RATE_MIN));
|
||
};
|
||
|
||
const getBaseMouseWheelDollyRate = () => getZoomDollyRate(activeZoomSpeed);
|
||
|
||
const isHexColor = (value) => /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(value || "").trim());
|
||
|
||
const normalizeCustomDisplayColor = (value) => {
|
||
const raw = String(value || "").trim();
|
||
if (!isHexColor(raw)) return DEFAULT_CUSTOM_DISPLAY_COLOR;
|
||
if (raw.length === 4) {
|
||
const [, r, g, b] = raw;
|
||
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
||
}
|
||
return raw.toLowerCase();
|
||
};
|
||
|
||
const normalizeCustomDisplaySaturation = (value) => {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return DEFAULT_CUSTOM_DISPLAY_SATURATION;
|
||
return Math.min(200, Math.max(0, Math.round(numeric)));
|
||
};
|
||
|
||
const customDisplayHexToRgb01 = (value) => {
|
||
const hex = normalizeCustomDisplayColor(value).slice(1);
|
||
return [
|
||
Number.parseInt(hex.slice(0, 2), 16) / 255,
|
||
Number.parseInt(hex.slice(2, 4), 16) / 255,
|
||
Number.parseInt(hex.slice(4, 6), 16) / 255,
|
||
];
|
||
};
|
||
|
||
const applyRgbSaturation = (rgb, saturation) => {
|
||
const factor = normalizeCustomDisplaySaturation(saturation) / 100;
|
||
const gray = (rgb[0] * 0.2126) + (rgb[1] * 0.7152) + (rgb[2] * 0.0722);
|
||
return rgb.map((channel) => Math.min(1, Math.max(0, gray + ((channel - gray) * factor))));
|
||
};
|
||
|
||
const getCustomDisplayColorize = () => (
|
||
applyRgbSaturation(customDisplayHexToRgb01(activeCustomDisplayColor), activeCustomDisplaySaturation)
|
||
);
|
||
|
||
const configureCameraPanMap = (control) => {
|
||
const input = viewer?.scene?.input;
|
||
if (!control || !input) return;
|
||
if (control.MOUSE_PAN === undefined || control.MOUSE_ROTATE === undefined || control.MOUSE_DOLLY === undefined) return;
|
||
|
||
control.panRightClick = true;
|
||
control.keyMap = {
|
||
...(control.keyMap || {}),
|
||
[control.MOUSE_PAN]: [
|
||
[input.MOUSE_LEFT_BUTTON, input.KEY_SHIFT],
|
||
input.MOUSE_MIDDLE_BUTTON,
|
||
input.MOUSE_RIGHT_BUTTON,
|
||
],
|
||
[control.MOUSE_ROTATE]: [input.MOUSE_LEFT_BUTTON],
|
||
[control.MOUSE_DOLLY]: [],
|
||
};
|
||
};
|
||
|
||
const configureCameraControl = () => {
|
||
const control = viewer?.cameraControl;
|
||
if (!control) return;
|
||
activeZoomSpeed = normalizeZoomSpeed(activeZoomSpeed);
|
||
configureCameraPanMap(control);
|
||
control.followPointer = true;
|
||
control.doublePickFlyTo = false;
|
||
control.smartPivot = true;
|
||
control.zoomOnMouseWheel = true;
|
||
control.keyboardDollyRate = CAMERA_CONTROL_SETTINGS.keyboardDollyRate;
|
||
control.mouseWheelDollyRate = getBaseMouseWheelDollyRate();
|
||
control.dollyInertia = CAMERA_CONTROL_SETTINGS.dollyInertia;
|
||
control.dollyMinSpeed = CAMERA_CONTROL_SETTINGS.dollyMinSpeed;
|
||
control.dollyProximityThreshold = CAMERA_CONTROL_SETTINGS.dollyProximityThreshold;
|
||
};
|
||
|
||
const setZoomSpeed = (value, { announce = false } = {}) => {
|
||
activeZoomSpeed = normalizeZoomSpeed(value);
|
||
zoomSpeedSlider?.setValue?.(activeZoomSpeed);
|
||
configureCameraControl();
|
||
if (announce) {
|
||
setStatus(`Скорость зума: ${activeZoomSpeed}%`);
|
||
}
|
||
};
|
||
|
||
const ensureCameraControlActive = () => {
|
||
if (!viewer || !viewer.cameraControl) return;
|
||
configureCameraControl();
|
||
if (viewer.cameraControl.enabled === false) {
|
||
viewer.cameraControl.enabled = true;
|
||
}
|
||
if (viewer.cameraControl.active === false) {
|
||
viewer.cameraControl.active = true;
|
||
}
|
||
};
|
||
|
||
const resetCameraControl = () => {
|
||
if (!viewer?.cameraControl) return;
|
||
// Toggle active to drop any stuck drag state without adding extra loops
|
||
viewer.cameraControl.active = false;
|
||
viewer.cameraControl.active = true;
|
||
};
|
||
|
||
const rearmCameraControl = () => {
|
||
ensureCameraControlActive();
|
||
resetCameraControl();
|
||
};
|
||
|
||
const focusCanvasAndControls = () => {
|
||
rearmCameraControl();
|
||
if (mainCanvas?.focus) {
|
||
mainCanvas.focus({ preventScroll: true });
|
||
}
|
||
};
|
||
|
||
const setToolbarButtonActive = (btn, active) => {
|
||
if (!btn) return;
|
||
btn.classList.toggle("active", !!active);
|
||
};
|
||
|
||
const updateProjectionButton = () => {
|
||
const mode = viewer?.camera?.projection || "perspective";
|
||
if (!projectionToggle) return;
|
||
if (mode === "ortho") {
|
||
projectionToggle.textContent = "P";
|
||
setToolbarButtonActive(projectionToggle, true);
|
||
} else {
|
||
projectionToggle.textContent = "A";
|
||
setToolbarButtonActive(projectionToggle, false);
|
||
}
|
||
};
|
||
|
||
const normalizeDisplayMode = (mode) => (
|
||
DISPLAY_MODE_OPTIONS.some((option) => option.value === mode) ? mode : "source"
|
||
);
|
||
|
||
const normalizeLightingMode = (mode) => (
|
||
LIGHTING_MODE_OPTIONS.some((option) => option.value === mode) ? mode : "technical"
|
||
);
|
||
|
||
const normalizeNavigationAxis = (axis) => (
|
||
NAVIGATION_AXIS_OPTIONS.some((option) => option.value === axis) ? axis : "auto"
|
||
);
|
||
|
||
const setCustomDisplaySettings = ({ color, saturation } = {}) => {
|
||
if (color !== undefined) {
|
||
activeCustomDisplayColor = normalizeCustomDisplayColor(color);
|
||
customDisplayColorControl?.setValue?.(activeCustomDisplayColor);
|
||
}
|
||
if (saturation !== undefined) {
|
||
activeCustomDisplaySaturation = normalizeCustomDisplaySaturation(saturation);
|
||
customDisplaySaturationSlider?.setValue?.(activeCustomDisplaySaturation);
|
||
}
|
||
};
|
||
|
||
const setCustomDisplayControlsState = () => {
|
||
const isCustom = normalizeDisplayMode(activeDisplayMode) === "custom";
|
||
customDisplayColorRow?.classList.toggle("hidden", !isCustom);
|
||
customDisplaySaturationControl?.classList.toggle("hidden", !isCustom);
|
||
customDisplayColorControl?.setValue?.(activeCustomDisplayColor);
|
||
customDisplaySaturationSlider?.setValue?.(activeCustomDisplaySaturation);
|
||
};
|
||
|
||
const setDisplayModeControlState = () => {
|
||
activeDisplayMode = normalizeDisplayMode(activeDisplayMode);
|
||
displayModeSelect?.setValue(activeDisplayMode);
|
||
setCustomDisplayControlsState();
|
||
};
|
||
|
||
const setLightingModeControlState = () => {
|
||
activeLightingMode = normalizeLightingMode(activeLightingMode);
|
||
lightingModeSelect?.setValue(activeLightingMode);
|
||
};
|
||
|
||
const setNavigationAxisControlState = () => {
|
||
activeNavigationAxis = normalizeNavigationAxis(activeNavigationAxis);
|
||
navigationAxisSelect?.setValue(activeNavigationAxis);
|
||
};
|
||
|
||
const normalizeModelSettingsSrc = (src) => {
|
||
if (!src || typeof src !== "string") return "";
|
||
let value = src.trim();
|
||
if (!value) return "";
|
||
try {
|
||
const parsed = new URL(value, window.location.href);
|
||
if (parsed.origin === window.location.origin || /^https?:/i.test(value)) {
|
||
value = parsed.pathname;
|
||
}
|
||
} catch (_) {
|
||
// keep raw value
|
||
}
|
||
value = value.replace(/^\/+/, "");
|
||
return value.startsWith("uploads/") ? value : "";
|
||
};
|
||
|
||
const normalizeCommentSourceSrc = (src) => {
|
||
if (!src || typeof src !== "string") return "";
|
||
let value = src.trim();
|
||
if (!value) return "";
|
||
try {
|
||
const parsed = new URL(value, window.location.href);
|
||
if (parsed.origin === window.location.origin) {
|
||
value = `${parsed.pathname}${parsed.search || ""}`;
|
||
} else if (/^https?:/i.test(value)) {
|
||
value = parsed.href;
|
||
}
|
||
} catch (_) {
|
||
// keep raw value
|
||
}
|
||
return value.replace(/^\/+/, "");
|
||
};
|
||
|
||
const getModelSettingsSrc = (entry) => {
|
||
const meta = entry?.meta || {};
|
||
const candidates = [
|
||
meta.settingsSrc,
|
||
meta.sourceSrc,
|
||
meta.originalSrc,
|
||
meta.conversion?.sourceSrc,
|
||
meta.manifest?.sourceSrc,
|
||
meta.artifactSrc,
|
||
entry?.src,
|
||
];
|
||
for (const candidate of candidates) {
|
||
const normalized = normalizeModelSettingsSrc(candidate);
|
||
if (normalized) return normalized;
|
||
}
|
||
return "";
|
||
};
|
||
|
||
const getPrimaryModelEntry = () => (
|
||
activeModels.find((entry) => entry?.model?.visible !== false) || activeModels[0] || null
|
||
);
|
||
|
||
const hasModelSettingsTarget = () => !!getModelSettingsSrc(getPrimaryModelEntry());
|
||
|
||
const isGuestMode = () => viewerMode === "guest";
|
||
const isGuestToolbarAction = (action) => GUEST_TOOLBAR_ACTIONS.has(action);
|
||
|
||
const getShareTokenFromPath = () => {
|
||
const match = window.location.pathname.match(/^\/share\/([^/?#]+)/);
|
||
return match ? decodeURIComponent(match[1]) : null;
|
||
};
|
||
|
||
const getCurrentReturnPath = () => `${window.location.pathname}${window.location.search || ""}${window.location.hash || ""}`;
|
||
|
||
const getShareTopLevelPromotionUrl = () => {
|
||
const promoteUrl = startupSharePayload?.optionalPromoteUrl || startupSharePayload?.promoteUrl;
|
||
return typeof promoteUrl === "string" && promoteUrl.trim() ? promoteUrl : "";
|
||
};
|
||
|
||
const buildLauncherOptionalServiceUrl = (nextPath = getCurrentReturnPath(), fallbackPath = nextPath) => {
|
||
const launchUrl = new URL("/api/services/bim-viewer/optional-launch", getLauncherUrl());
|
||
launchUrl.searchParams.set("returnTo", nextPath);
|
||
launchUrl.searchParams.set("fallbackTo", fallbackPath);
|
||
return launchUrl.toString();
|
||
};
|
||
|
||
const hasShareGuestAuthMarker = () => {
|
||
try {
|
||
return new URLSearchParams(window.location.search || "").get(SHARE_AUTH_GUEST_PARAM) === "1";
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const clearShareGuestAuthMarker = () => {
|
||
if (!hasShareGuestAuthMarker()) return;
|
||
try {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.delete(SHARE_AUTH_GUEST_PARAM);
|
||
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
|
||
} catch (_) {
|
||
// keep current URL if the History API is unavailable
|
||
}
|
||
};
|
||
|
||
const clearShareStartupPending = () => {
|
||
if (document.documentElement?.dataset?.shareStartup === "pending") {
|
||
delete document.documentElement.dataset.shareStartup;
|
||
}
|
||
};
|
||
|
||
const isNodeDCSessionLogoutEvent = (value) => (
|
||
value &&
|
||
typeof value === "object" &&
|
||
value.type === NODEDC_SESSION_LOGOUT_EVENT_TYPE &&
|
||
typeof value.id === "string"
|
||
);
|
||
|
||
const isNodeDCSessionLoginEvent = (value) => (
|
||
value &&
|
||
typeof value === "object" &&
|
||
value.type === NODEDC_SESSION_LOGIN_EVENT_TYPE &&
|
||
typeof value.id === "string"
|
||
);
|
||
|
||
let nodeDCSessionSyncCleanup = null;
|
||
let nodeDCLogoutHandling = false;
|
||
let nodeDCLoginHandling = false;
|
||
let nodeDCSessionWatchTimer = null;
|
||
let nodeDCSessionWatchInFlight = false;
|
||
let nodeDCLoginSyncPrimed = false;
|
||
let nodeDCLoginSyncEventId = "";
|
||
|
||
const handleNodeDCSessionLogout = async () => {
|
||
if (nodeDCLogoutHandling) return;
|
||
nodeDCLogoutHandling = true;
|
||
|
||
const logoutRequest = fetch("/logout", {
|
||
method: "GET",
|
||
credentials: "include",
|
||
keepalive: true,
|
||
cache: "no-store",
|
||
})
|
||
.catch(() => undefined);
|
||
|
||
runtimeSessionInfo = {
|
||
...runtimeSessionInfo,
|
||
authenticated: false,
|
||
canShare: false,
|
||
user: null,
|
||
};
|
||
|
||
if (startupSharePayload) {
|
||
startupSharePayload = {
|
||
...startupSharePayload,
|
||
mode: "guest",
|
||
readonly: true,
|
||
authenticated: false,
|
||
canShare: false,
|
||
user: null,
|
||
};
|
||
}
|
||
|
||
if (getShareTokenFromPath()) {
|
||
viewerMode = "guest";
|
||
inspectorCollapsed = true;
|
||
applyRuntimeMode();
|
||
updatePanelsVisibility();
|
||
nodeDCLogoutHandling = false;
|
||
return;
|
||
}
|
||
|
||
await Promise.race([
|
||
logoutRequest,
|
||
new Promise((resolve) => window.setTimeout(resolve, 500)),
|
||
]);
|
||
|
||
const loginUrl = new URL("/auth/login", getLauncherUrl());
|
||
window.location.replace(loginUrl.toString());
|
||
};
|
||
|
||
const handleNodeDCSessionLogin = () => {
|
||
if (nodeDCLoginHandling || runtimeSessionInfo.authenticated === true) return;
|
||
nodeDCLoginHandling = true;
|
||
|
||
const shareToken = getShareTokenFromPath();
|
||
if (shareToken) {
|
||
const promotionUrl = getShareTopLevelPromotionUrl() ||
|
||
buildLauncherOptionalServiceUrl(
|
||
`/share/${encodeURIComponent(shareToken)}`,
|
||
`/share/${encodeURIComponent(shareToken)}?${SHARE_AUTH_GUEST_PARAM}=1`
|
||
);
|
||
window.location.assign(promotionUrl);
|
||
return;
|
||
}
|
||
|
||
window.location.assign(buildLauncherOptionalServiceUrl());
|
||
};
|
||
|
||
const setupNodeDCSessionSync = () => {
|
||
if (nodeDCSessionSyncCleanup || typeof document === "undefined") return;
|
||
|
||
let launcherOrigin = "";
|
||
try {
|
||
launcherOrigin = new URL(getLauncherUrl()).origin;
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
|
||
const iframe = document.createElement("iframe");
|
||
iframe.src = new URL("/auth/session-sync", launcherOrigin).toString();
|
||
iframe.title = "NODE.DC session sync";
|
||
iframe.tabIndex = -1;
|
||
iframe.setAttribute("aria-hidden", "true");
|
||
iframe.style.cssText = "position:fixed;width:0;height:0;opacity:0;pointer-events:none;border:0;";
|
||
document.body.appendChild(iframe);
|
||
|
||
const handleMessage = (event) => {
|
||
if (event.origin !== launcherOrigin) return;
|
||
if (isNodeDCSessionLogoutEvent(event.data)) {
|
||
void handleNodeDCSessionLogout();
|
||
return;
|
||
}
|
||
if (isNodeDCSessionLoginEvent(event.data)) {
|
||
handleNodeDCSessionLogin();
|
||
}
|
||
};
|
||
|
||
window.addEventListener("message", handleMessage);
|
||
nodeDCSessionSyncCleanup = () => {
|
||
window.removeEventListener("message", handleMessage);
|
||
iframe.remove();
|
||
nodeDCSessionSyncCleanup = null;
|
||
};
|
||
};
|
||
|
||
const scheduleShareSessionPromotion = (shareToken) => {
|
||
const silentPromoteUrl = startupSharePayload?.silentPromoteUrl;
|
||
if (
|
||
!shareToken ||
|
||
hasShareGuestAuthMarker() ||
|
||
viewerMode !== "guest" ||
|
||
runtimeSessionInfo.authenticated ||
|
||
typeof silentPromoteUrl !== "string" ||
|
||
!silentPromoteUrl.trim()
|
||
) {
|
||
return;
|
||
}
|
||
let storageKey = "";
|
||
try {
|
||
storageKey = `bimdc-share-promote:${shareToken}`;
|
||
const lastAttempt = Number(sessionStorage.getItem(storageKey) || 0);
|
||
if (Number.isFinite(lastAttempt) && Date.now() - lastAttempt < SHARE_PROMOTION_RETRY_MS) {
|
||
return;
|
||
}
|
||
sessionStorage.setItem(storageKey, String(Date.now()));
|
||
} catch (_) {
|
||
storageKey = "";
|
||
}
|
||
|
||
const iframe = document.createElement("iframe");
|
||
iframe.title = "BIM share auth promotion";
|
||
iframe.tabIndex = -1;
|
||
iframe.setAttribute("aria-hidden", "true");
|
||
iframe.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:-10000px;border:0;opacity:0;pointer-events:none;";
|
||
|
||
let done = false;
|
||
let timeoutId = null;
|
||
const cleanup = () => {
|
||
window.removeEventListener("message", onMessage);
|
||
if (timeoutId) window.clearTimeout(timeoutId);
|
||
iframe.remove();
|
||
};
|
||
const onMessage = (event) => {
|
||
if (event.origin !== window.location.origin) return;
|
||
const payload = event.data || {};
|
||
if (payload.type !== "nodedc:bim-share-promoted") return;
|
||
done = true;
|
||
cleanup();
|
||
if (payload.promoted === false) return;
|
||
window.location.replace(payload.nextPath || getCurrentReturnPath());
|
||
};
|
||
|
||
window.addEventListener("message", onMessage);
|
||
timeoutId = window.setTimeout(() => {
|
||
if (done) return;
|
||
cleanup();
|
||
}, SHARE_PROMOTION_TIMEOUT_MS);
|
||
iframe.src = silentPromoteUrl;
|
||
document.body.appendChild(iframe);
|
||
};
|
||
|
||
const getLoginSyncEventId = (sessionPayload) => (
|
||
typeof sessionPayload?.loginSyncEventId === "string" ? sessionPayload.loginSyncEventId : ""
|
||
);
|
||
|
||
const applyLiveSessionPayload = async (sessionPayload, { reason = "watch" } = {}) => {
|
||
const wasAuthenticated = runtimeSessionInfo.authenticated === true;
|
||
const isAuthenticated = sessionPayload?.authenticated === true;
|
||
const loginSyncEventId = getLoginSyncEventId(sessionPayload);
|
||
const hasNewLoginSyncEvent =
|
||
nodeDCLoginSyncPrimed &&
|
||
!isAuthenticated &&
|
||
!!loginSyncEventId &&
|
||
loginSyncEventId !== nodeDCLoginSyncEventId;
|
||
|
||
nodeDCLoginSyncPrimed = true;
|
||
if (loginSyncEventId) {
|
||
nodeDCLoginSyncEventId = loginSyncEventId;
|
||
}
|
||
|
||
runtimeSessionInfo = {
|
||
...runtimeSessionInfo,
|
||
...(sessionPayload || {}),
|
||
authenticated: isAuthenticated,
|
||
canShare: sessionPayload?.canShare === true,
|
||
user: sessionPayload?.user || null,
|
||
};
|
||
|
||
if (!isAuthenticated) {
|
||
if (wasAuthenticated) {
|
||
await handleNodeDCSessionLogout();
|
||
return;
|
||
}
|
||
if (hasNewLoginSyncEvent) {
|
||
handleNodeDCSessionLogin();
|
||
return;
|
||
}
|
||
const shareToken = getShareTokenFromPath();
|
||
if (shareToken && viewerMode === "guest") {
|
||
scheduleShareSessionPromotion(shareToken);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (getShareTokenFromPath() && viewerMode === "guest") {
|
||
viewerMode = "standard";
|
||
}
|
||
applyRuntimeMode();
|
||
updatePanelsVisibility();
|
||
|
||
if (reason === "focus" || reason === "visibility") {
|
||
loadProjectsList?.().catch(() => {});
|
||
}
|
||
};
|
||
|
||
const refreshNodeDCSessionStatus = async (options = {}) => {
|
||
if (nodeDCSessionWatchInFlight || nodeDCLogoutHandling) return;
|
||
nodeDCSessionWatchInFlight = true;
|
||
try {
|
||
const response = await fetch(AUTH_SESSION_API, {
|
||
credentials: "include",
|
||
cache: "no-store",
|
||
headers: { "Accept": "application/json" },
|
||
});
|
||
if (response.ok) {
|
||
await applyLiveSessionPayload(await response.json(), options);
|
||
}
|
||
} catch (_) {
|
||
// A transient network failure must not log the user out client-side.
|
||
} finally {
|
||
nodeDCSessionWatchInFlight = false;
|
||
}
|
||
};
|
||
|
||
const setupNodeDCSessionWatcher = () => {
|
||
if (nodeDCSessionWatchTimer || typeof window === "undefined") return;
|
||
|
||
nodeDCSessionWatchTimer = window.setInterval(() => {
|
||
void refreshNodeDCSessionStatus({ reason: "interval" });
|
||
}, SESSION_STATUS_WATCH_INTERVAL_MS);
|
||
|
||
window.addEventListener("focus", () => {
|
||
void refreshNodeDCSessionStatus({ reason: "focus" });
|
||
const shareToken = getShareTokenFromPath();
|
||
if (shareToken && viewerMode === "guest") {
|
||
scheduleShareSessionPromotion(shareToken);
|
||
}
|
||
});
|
||
|
||
window.addEventListener("pageshow", () => {
|
||
void refreshNodeDCSessionStatus({ reason: "pageshow" });
|
||
});
|
||
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.visibilityState !== "visible") return;
|
||
void refreshNodeDCSessionStatus({ reason: "visibility" });
|
||
const shareToken = getShareTokenFromPath();
|
||
if (shareToken && viewerMode === "guest") {
|
||
scheduleShareSessionPromotion(shareToken);
|
||
}
|
||
});
|
||
};
|
||
|
||
const COMMENT_TARGET_ICON = `
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M6.5 4.75h11A3.25 3.25 0 0 1 20.75 8v6.25a3.25 3.25 0 0 1-3.25 3.25h-5.26l-5.29 3.82V17.5H6.5a3.25 3.25 0 0 1-3.25-3.25V8A3.25 3.25 0 0 1 6.5 4.75Z"></path>
|
||
</svg>
|
||
`;
|
||
|
||
const formatDateTime = (value) => {
|
||
if (!value) return "";
|
||
try {
|
||
return new Intl.DateTimeFormat("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(new Date(value));
|
||
} catch (_) {
|
||
return String(value);
|
||
}
|
||
};
|
||
|
||
const setCommentModalError = (message = "") => {
|
||
if (!commentModalError) return;
|
||
const text = String(message || "").trim();
|
||
commentModalError.textContent = text;
|
||
commentModalError.hidden = !text;
|
||
};
|
||
|
||
const setCommentModalSubtitle = (text = "") => {
|
||
if (!commentModalSubtitle) return;
|
||
const value = String(text || "").trim();
|
||
commentModalSubtitle.textContent = value;
|
||
commentModalSubtitle.hidden = !value;
|
||
};
|
||
|
||
const bringFloatingWindowToFront = (id) => {
|
||
if (!id) return;
|
||
floatingWindowOrder = [...floatingWindowOrder.filter((item) => item !== id), id];
|
||
floatingWindowOrder.forEach((item, index) => {
|
||
const element = floatingWindowElements.get(item);
|
||
if (element) element.style.zIndex = `${WORKSPACE_WINDOW_Z_BASE + index * WORKSPACE_WINDOW_Z_STEP}`;
|
||
});
|
||
};
|
||
|
||
const registerFloatingWindowElement = (id, element) => {
|
||
if (!id || !element) return;
|
||
floatingWindowElements.set(id, element);
|
||
if (!floatingWindowOrder.includes(id)) floatingWindowOrder.push(id);
|
||
element.addEventListener("pointerdown", (event) => {
|
||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||
bringFloatingWindowToFront(id);
|
||
}, true);
|
||
bringFloatingWindowToFront(id);
|
||
};
|
||
|
||
const extractUploadIdentityFromSrc = (src) => {
|
||
if (!src || typeof src !== "string") return {};
|
||
const match = src.match(/(?:^|\/)uploads\/([^/]+)\/([^/?#]+)/);
|
||
return match ? { projectId: match[1], assetId: match[2] } : {};
|
||
};
|
||
|
||
const getFallbackCommentSourceSrc = (entry) => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const querySrc = normalizeCommentSourceSrc(params.get("src") || params.get("url") || "");
|
||
if (querySrc) return querySrc;
|
||
const modelId = entry?.model?.id || entry?.id || "model";
|
||
if (currentProjectMeta?.id) {
|
||
return `project:${currentProjectMeta.id}:model:${modelId}`;
|
||
}
|
||
return `viewer:${window.location.pathname}${window.location.search || ""}:model:${modelId}`;
|
||
};
|
||
|
||
const getCommentSourceForEntry = (entry = getPrimaryModelEntry()) => {
|
||
if (!entry) return null;
|
||
const meta = entry.meta || {};
|
||
const src = getModelSettingsSrc(entry) ||
|
||
normalizeCommentSourceSrc(meta.sourceSrc) ||
|
||
normalizeCommentSourceSrc(meta.artifactSrc) ||
|
||
normalizeCommentSourceSrc(meta.settingsSrc) ||
|
||
normalizeCommentSourceSrc(entry.src) ||
|
||
getFallbackCommentSourceSrc(entry);
|
||
const uploadIdentity = extractUploadIdentityFromSrc(src);
|
||
return {
|
||
src,
|
||
sourceSrc: src,
|
||
settingsSrc: normalizeModelSettingsSrc(meta.settingsSrc) || src,
|
||
projectId: meta.projectId || uploadIdentity.projectId || currentProjectMeta?.id || null,
|
||
assetId: meta.assetId || uploadIdentity.assetId || null,
|
||
versionId: meta.versionId || null,
|
||
};
|
||
};
|
||
|
||
const sourceParamsForCommentSource = (source) => {
|
||
const params = new URLSearchParams();
|
||
if (!source) return params;
|
||
if (source.projectId) params.set("projectId", source.projectId);
|
||
if (source.assetId) params.set("assetId", source.assetId);
|
||
if (source.src) params.set("src", source.src);
|
||
if (source.sourceSrc) params.set("sourceSrc", source.sourceSrc);
|
||
if (source.settingsSrc) params.set("settingsSrc", source.settingsSrc);
|
||
return params;
|
||
};
|
||
|
||
const sourceCacheKey = (source) => {
|
||
const params = sourceParamsForCommentSource(source);
|
||
return params.toString();
|
||
};
|
||
|
||
const mergeCommentRecords = (items) => {
|
||
const byId = new Map(commentRecords.map((comment) => [comment.id, comment]));
|
||
(items || []).forEach((comment) => {
|
||
if (comment?.id) byId.set(comment.id, comment);
|
||
});
|
||
commentRecords = [...byId.values()]
|
||
.filter((comment) => !comment.deletedAt)
|
||
.sort((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
|
||
};
|
||
|
||
const loadCommentsForEntry = async (entry, { force = false } = {}) => {
|
||
if (!entry || isGuestMode()) return [];
|
||
const source = getCommentSourceForEntry(entry);
|
||
const key = sourceCacheKey(source);
|
||
if (!key) return [];
|
||
if (!force && commentSourcesLoaded.has(key)) return commentRecords;
|
||
const params = sourceParamsForCommentSource(source);
|
||
const payload = await fetchJSON(`${COMMENTS_API}?${params.toString()}`);
|
||
commentSourcesLoaded.add(key);
|
||
mergeCommentRecords(payload?.comments || []);
|
||
renderCommentsPanel();
|
||
renderCommentTargets();
|
||
return payload?.comments || [];
|
||
};
|
||
|
||
const loadCommentsForActiveModels = async ({ force = false } = {}) => {
|
||
if (isGuestMode()) return;
|
||
const entries = activeModels.length ? [...activeModels] : [getPrimaryModelEntry()].filter(Boolean);
|
||
for (const entry of entries) {
|
||
await loadCommentsForEntry(entry, { force }).catch((err) => {
|
||
console.warn("comments load failed", err);
|
||
});
|
||
}
|
||
};
|
||
|
||
const getCommentDisplayBody = (comment) => {
|
||
if (comment?.body) return comment.body;
|
||
const lastMessage = Array.isArray(comment?.messages) ? comment.messages[comment.messages.length - 1] : null;
|
||
return lastMessage?.body || "";
|
||
};
|
||
|
||
const syncCommentListActiveState = () => {
|
||
if (!commentsList) return;
|
||
commentsList.querySelectorAll(".comment-list-item").forEach((item) => {
|
||
const commentId = item.dataset.commentId;
|
||
item.classList.toggle("active", activeCommentId === commentId || hoverCommentId === commentId);
|
||
});
|
||
};
|
||
|
||
const renderCommentsPanel = () => {
|
||
if (!commentsList) return;
|
||
commentsList.replaceChildren();
|
||
if (!commentsModeActive) return;
|
||
if (!commentRecords.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "comments-empty";
|
||
empty.textContent = "Комментариев пока нет";
|
||
commentsList.appendChild(empty);
|
||
return;
|
||
}
|
||
commentRecords.forEach((comment) => {
|
||
const item = document.createElement("button");
|
||
item.type = "button";
|
||
item.className = "comment-list-item";
|
||
item.classList.toggle("active", activeCommentId === comment.id || hoverCommentId === comment.id);
|
||
item.dataset.commentId = comment.id;
|
||
const title = document.createElement("div");
|
||
title.className = "comment-list-title";
|
||
title.textContent = comment.title || "Комментарий";
|
||
const body = document.createElement("div");
|
||
body.className = "comment-list-body";
|
||
body.textContent = getCommentDisplayBody(comment) || "Без описания";
|
||
const meta = document.createElement("div");
|
||
meta.className = "comment-list-meta";
|
||
const created = formatDateTime(comment.createdAt);
|
||
const updated = formatDateTime(comment.updatedAt);
|
||
meta.textContent = updated && updated !== created ? `Создан: ${created} · Изменён: ${updated}` : `Создан: ${created}`;
|
||
item.append(title, body, meta);
|
||
item.addEventListener("mouseenter", () => {
|
||
hoverCommentId = comment.id;
|
||
syncCommentListActiveState();
|
||
renderCommentTargets();
|
||
});
|
||
item.addEventListener("mouseleave", () => {
|
||
if (hoverCommentId === comment.id) hoverCommentId = null;
|
||
syncCommentListActiveState();
|
||
renderCommentTargets();
|
||
});
|
||
item.addEventListener("click", () => openCommentModalForComment(comment.id));
|
||
commentsList.appendChild(item);
|
||
});
|
||
};
|
||
|
||
const commentTargetsForRender = () => {
|
||
const targets = [...commentRecords];
|
||
if (commentModalMode === "create" && commentContextTarget?.worldPos) {
|
||
targets.push({
|
||
id: "__draft_comment__",
|
||
title: "Новый комментарий",
|
||
worldPos: commentContextTarget.worldPos,
|
||
draft: true,
|
||
});
|
||
}
|
||
return targets;
|
||
};
|
||
|
||
const updateCommentTargetPositions = () => {
|
||
if (!viewer?.scene?.camera || !commentTargetsLayer || !commentsModeActive) return;
|
||
const canvas = viewer.scene?.canvas?.canvas || mainCanvas;
|
||
const rect = canvas?.getBoundingClientRect?.();
|
||
if (!rect) return;
|
||
commentTargetsLayer.querySelectorAll(".comment-target").forEach((targetEl) => {
|
||
const id = targetEl.dataset.commentId;
|
||
const record = commentTargetsForRender().find((comment) => comment.id === id);
|
||
const pos = record?.worldPos;
|
||
if (!Array.isArray(pos) || pos.length !== 3) {
|
||
targetEl.hidden = true;
|
||
return;
|
||
}
|
||
let canvasPos = null;
|
||
try {
|
||
canvasPos = viewer.scene.camera.projectWorldPos(pos);
|
||
} catch (_) {
|
||
canvasPos = null;
|
||
}
|
||
if (!canvasPos || !Number.isFinite(canvasPos[0]) || !Number.isFinite(canvasPos[1])) {
|
||
targetEl.hidden = true;
|
||
return;
|
||
}
|
||
targetEl.hidden = false;
|
||
targetEl.style.left = `${Math.round(rect.left + canvasPos[0])}px`;
|
||
targetEl.style.top = `${Math.round(rect.top + canvasPos[1])}px`;
|
||
});
|
||
};
|
||
|
||
const startCommentTargetLoop = () => {
|
||
if (commentTargetRaf) return;
|
||
const tick = () => {
|
||
commentTargetRaf = 0;
|
||
if (!commentsModeActive) return;
|
||
updateCommentTargetPositions();
|
||
commentTargetRaf = window.requestAnimationFrame(tick);
|
||
};
|
||
commentTargetRaf = window.requestAnimationFrame(tick);
|
||
};
|
||
|
||
const stopCommentTargetLoop = () => {
|
||
if (commentTargetRaf) {
|
||
window.cancelAnimationFrame(commentTargetRaf);
|
||
commentTargetRaf = 0;
|
||
}
|
||
};
|
||
|
||
const syncCommentTargetActiveState = () => {
|
||
if (!commentTargetsLayer) return;
|
||
commentTargetsLayer.querySelectorAll(".comment-target").forEach((targetEl) => {
|
||
const commentId = targetEl.dataset.commentId;
|
||
targetEl.classList.toggle(
|
||
"active",
|
||
commentId === "__draft_comment__" || activeCommentId === commentId || hoverCommentId === commentId
|
||
);
|
||
});
|
||
};
|
||
|
||
const renderCommentTargets = () => {
|
||
if (!commentTargetsLayer) return;
|
||
commentTargetsLayer.replaceChildren();
|
||
if (!commentsModeActive || isGuestMode()) {
|
||
commentTargetsLayer.classList.add("hidden");
|
||
stopCommentTargetLoop();
|
||
return;
|
||
}
|
||
const targets = commentTargetsForRender();
|
||
targets.forEach((comment) => {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "comment-target";
|
||
btn.classList.toggle("active", comment.draft || activeCommentId === comment.id || hoverCommentId === comment.id);
|
||
btn.dataset.commentId = comment.id;
|
||
btn.title = comment.title || "Комментарий";
|
||
btn.setAttribute("aria-label", comment.title || "Комментарий");
|
||
btn.innerHTML = COMMENT_TARGET_ICON;
|
||
btn.addEventListener("mouseenter", () => {
|
||
if (!comment.draft) hoverCommentId = comment.id;
|
||
syncCommentTargetActiveState();
|
||
syncCommentListActiveState();
|
||
});
|
||
btn.addEventListener("mouseleave", () => {
|
||
if (hoverCommentId === comment.id) hoverCommentId = null;
|
||
syncCommentTargetActiveState();
|
||
syncCommentListActiveState();
|
||
});
|
||
btn.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
if (!comment.draft) openCommentModalForComment(comment.id);
|
||
});
|
||
commentTargetsLayer.appendChild(btn);
|
||
});
|
||
commentTargetsLayer.classList.toggle("hidden", targets.length === 0);
|
||
updateCommentTargetPositions();
|
||
startCommentTargetLoop();
|
||
};
|
||
|
||
const updateCommentsVisibility = () => {
|
||
const visible = commentsModeActive && !isGuestMode() && activeModels.length > 0;
|
||
setToolbarButtonActive(commentsButton, visible);
|
||
if (commentsPanel) {
|
||
commentsPanel.classList.toggle("hidden", !visible || commentsCollapsed);
|
||
}
|
||
if (commentsRestoreTray) {
|
||
commentsRestoreTray.hidden = !visible || !commentsCollapsed;
|
||
}
|
||
if (visible) {
|
||
renderCommentsPanel();
|
||
renderCommentTargets();
|
||
loadCommentsForActiveModels().catch(() => {});
|
||
} else {
|
||
commentsPanel?.classList.add("hidden");
|
||
if (commentsRestoreTray) commentsRestoreTray.hidden = true;
|
||
commentTargetsLayer?.classList.add("hidden");
|
||
hideCommentContextMenu();
|
||
stopCommentTargetLoop();
|
||
}
|
||
};
|
||
|
||
const setCommentsCollapsed = (collapsed) => {
|
||
commentsCollapsed = !!collapsed;
|
||
updateCommentsVisibility();
|
||
};
|
||
|
||
const toggleCommentsMode = async () => {
|
||
if (isGuestMode()) return;
|
||
if (!activeModels.length) {
|
||
setStatus("Сначала загрузите модель");
|
||
return;
|
||
}
|
||
commentsModeActive = !commentsModeActive;
|
||
if (commentsModeActive) {
|
||
commentsCollapsed = false;
|
||
bringFloatingWindowToFront("comments");
|
||
await loadCommentsForActiveModels({ force: true }).catch(() => {});
|
||
} else {
|
||
activeCommentId = null;
|
||
hoverCommentId = null;
|
||
}
|
||
updateCommentsVisibility();
|
||
};
|
||
|
||
const hideCommentContextMenu = () => {
|
||
commentContextMenu?.classList.add("hidden");
|
||
};
|
||
|
||
const clampMenuPosition = (x, y, menu) => {
|
||
const width = menu?.offsetWidth || 220;
|
||
const height = menu?.offsetHeight || 54;
|
||
return {
|
||
x: Math.max(8, Math.min(window.innerWidth - width - 8, x)),
|
||
y: Math.max(8, Math.min(window.innerHeight - height - 8, y)),
|
||
};
|
||
};
|
||
|
||
const getCanvasPointFromEvent = (event) => {
|
||
const canvas = viewer?.scene?.canvas?.canvas || mainCanvas;
|
||
const rect = canvas?.getBoundingClientRect?.();
|
||
if (!rect) return null;
|
||
return {
|
||
canvasPos: [event.clientX - rect.left, event.clientY - rect.top],
|
||
pagePos: [event.clientX, event.clientY],
|
||
};
|
||
};
|
||
|
||
const getDollyFactorForDistance = (distance) => {
|
||
const numeric = Number(distance);
|
||
if (!Number.isFinite(numeric) || numeric <= 0) return 1;
|
||
return Math.max(
|
||
CAMERA_CONTROL_SETTINGS.dollyMinSpeed,
|
||
numeric / CAMERA_CONTROL_SETTINGS.dollyProximityThreshold,
|
||
);
|
||
};
|
||
|
||
const getDollyFactorForWorldPos = (worldPos) => {
|
||
const eye = viewer?.camera?.eye || viewer?.scene?.camera?.eye;
|
||
if (!eye || !worldPos || worldPos.length < 3) return null;
|
||
const distance = Math.hypot(
|
||
Number(worldPos[0]) - Number(eye[0]),
|
||
Number(worldPos[1]) - Number(eye[1]),
|
||
Number(worldPos[2]) - Number(eye[2]),
|
||
);
|
||
return Number.isFinite(distance) ? getDollyFactorForDistance(distance) : null;
|
||
};
|
||
|
||
const getCameraFocusDollyFactor = () => {
|
||
const camera = viewer?.camera || viewer?.scene?.camera;
|
||
const directDistance = Number(camera?.eyeLookDist);
|
||
if (Number.isFinite(directDistance) && directDistance > 0) {
|
||
return getDollyFactorForDistance(directDistance);
|
||
}
|
||
const eye = camera?.eye;
|
||
const look = camera?.look;
|
||
if (!eye || !look) return 1;
|
||
const distance = Math.hypot(
|
||
Number(look[0]) - Number(eye[0]),
|
||
Number(look[1]) - Number(eye[1]),
|
||
Number(look[2]) - Number(eye[2]),
|
||
);
|
||
return Number.isFinite(distance) ? getDollyFactorForDistance(distance) : 1;
|
||
};
|
||
|
||
const rememberSurfaceDollyFactor = (hit) => {
|
||
const factor = getDollyFactorForWorldPos(hit?.worldPos);
|
||
if (factor === null) return;
|
||
lastSurfaceDollyFactor = factor;
|
||
lastSurfaceDollyFactorAt = window.performance?.now?.() || Date.now();
|
||
};
|
||
|
||
const getEmptyWheelDollyFactor = () => {
|
||
const focusFactor = getCameraFocusDollyFactor();
|
||
const now = window.performance?.now?.() || Date.now();
|
||
const hasFreshSurfaceFactor = (
|
||
lastSurfaceDollyFactor !== null &&
|
||
now - lastSurfaceDollyFactorAt <= LAST_SURFACE_DOLLY_FACTOR_TTL
|
||
);
|
||
return hasFreshSurfaceFactor ? Math.min(focusFactor, lastSurfaceDollyFactor) : focusFactor;
|
||
};
|
||
|
||
const restorePointerNavigation = () => {
|
||
if (emptyWheelNavigationRestoreTimer) {
|
||
window.clearTimeout(emptyWheelNavigationRestoreTimer);
|
||
emptyWheelNavigationRestoreTimer = null;
|
||
}
|
||
const control = viewer?.cameraControl;
|
||
if (!control) return;
|
||
control.followPointer = true;
|
||
control.smartPivot = true;
|
||
control.mouseWheelDollyRate = getBaseMouseWheelDollyRate();
|
||
};
|
||
|
||
const schedulePointerNavigationRestore = () => {
|
||
if (emptyWheelNavigationRestoreTimer) {
|
||
window.clearTimeout(emptyWheelNavigationRestoreTimer);
|
||
}
|
||
emptyWheelNavigationRestoreTimer = window.setTimeout(() => {
|
||
emptyWheelNavigationRestoreTimer = null;
|
||
restorePointerNavigation();
|
||
}, 80);
|
||
};
|
||
|
||
const getWheelSurfaceHit = (event) => {
|
||
if (!activeModels.length || !viewer?.scene?.pick) return true;
|
||
const point = getCanvasPointFromEvent(event);
|
||
if (!point) return true;
|
||
try {
|
||
return viewer.scene.pick({
|
||
canvasPos: point.canvasPos,
|
||
pickSurface: true,
|
||
});
|
||
} catch (_) {
|
||
return true;
|
||
}
|
||
};
|
||
|
||
const guardEmptyWheelNavigation = (event) => {
|
||
const control = viewer?.cameraControl;
|
||
if (!control) return;
|
||
const hit = getWheelSurfaceHit(event);
|
||
if (hit === true || hit?.entity || hit?.worldPos) {
|
||
rememberSurfaceDollyFactor(hit === true ? null : hit);
|
||
restorePointerNavigation();
|
||
return;
|
||
}
|
||
control.followPointer = true;
|
||
control.smartPivot = true;
|
||
control.mouseWheelDollyRate = getBaseMouseWheelDollyRate() * getEmptyWheelDollyFactor();
|
||
schedulePointerNavigationRestore();
|
||
};
|
||
|
||
const getWorldPosFromPick = (hit) => {
|
||
const direct = hit?.worldPos || hit?.worldPosition || hit?.position || hit?.origin;
|
||
if (direct && direct.length >= 3) {
|
||
return [Number(direct[0]), Number(direct[1]), Number(direct[2])].every(Number.isFinite)
|
||
? [Number(direct[0]), Number(direct[1]), Number(direct[2])]
|
||
: null;
|
||
}
|
||
const entity = hit?.entity;
|
||
const aabb = entity?.aabb || entity?.model?.aabb || null;
|
||
const aabbArray = Array.from(aabb || []);
|
||
return aabbArray.length === 6 ? centerFromAABB(aabbArray) : null;
|
||
};
|
||
|
||
const buildCommentTargetFromEvent = (event) => {
|
||
if (isGuestMode() || !viewer?.scene) return null;
|
||
const point = getCanvasPointFromEvent(event);
|
||
if (!point) return null;
|
||
const hit = viewer.scene.pick({
|
||
canvasPos: point.canvasPos,
|
||
pickSurface: true,
|
||
});
|
||
if (!hit) return null;
|
||
const entity = hit.entity || null;
|
||
const modelId = entity?.model?.id || entity?.modelId || null;
|
||
const objectId = entity?.id || entity?.objectId || null;
|
||
const entry = activeModels.find((item) => item?.model?.id === modelId) || getPrimaryModelEntry();
|
||
const source = getCommentSourceForEntry(entry);
|
||
const worldPos = getWorldPosFromPick(hit);
|
||
if (!source || !worldPos) return null;
|
||
return {
|
||
...source,
|
||
modelId,
|
||
objectId,
|
||
worldPos,
|
||
canvasPos: point.canvasPos,
|
||
pagePos: point.pagePos,
|
||
};
|
||
};
|
||
|
||
const showCommentContextMenu = (target, x, y) => {
|
||
if (!commentContextMenu || !target) return;
|
||
commentContextTarget = target;
|
||
commentContextMenu.classList.remove("hidden");
|
||
const pos = clampMenuPosition(x, y, commentContextMenu);
|
||
commentContextMenu.style.left = `${pos.x}px`;
|
||
commentContextMenu.style.top = `${pos.y}px`;
|
||
};
|
||
|
||
const openCommentContextMenuFromEvent = (event) => {
|
||
if (isGuestMode()) return false;
|
||
const target = buildCommentTargetFromEvent(event);
|
||
if (!target) {
|
||
hideCommentContextMenu();
|
||
return false;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
showCommentContextMenu(target, event.clientX, event.clientY);
|
||
return true;
|
||
};
|
||
|
||
const COMMENT_FILE_LIMIT_BYTES = 50 * 1024 * 1024;
|
||
|
||
const readAttachmentFiles = async (fileList) => {
|
||
const files = Array.from(fileList || [])
|
||
.filter((file) => file && file.size <= COMMENT_FILE_LIMIT_BYTES)
|
||
.slice(0, 8);
|
||
const skipped = Array.from(fileList || []).filter((file) => file && file.size > COMMENT_FILE_LIMIT_BYTES).length;
|
||
if (skipped) {
|
||
setCommentModalError(`Файлы больше 50 МБ не добавлены: ${skipped}`);
|
||
}
|
||
const readOne = (file) => new Promise((resolve) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve({
|
||
id: `att_${Date.now()}_${Math.random().toString(36).slice(2)}`,
|
||
name: file.name,
|
||
type: file.type,
|
||
size: file.size,
|
||
dataUrl: String(reader.result || ""),
|
||
});
|
||
reader.onerror = () => resolve(null);
|
||
reader.readAsDataURL(file);
|
||
});
|
||
const items = await Promise.all(files.map(readOne));
|
||
return items.filter(Boolean);
|
||
};
|
||
|
||
const formatFileSize = (value) => {
|
||
const size = Number(value);
|
||
if (!Number.isFinite(size) || size <= 0) return "";
|
||
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(size >= 10 * 1024 * 1024 ? 0 : 1)} МБ`;
|
||
if (size >= 1024) return `${Math.round(size / 1024)} КБ`;
|
||
return `${Math.round(size)} Б`;
|
||
};
|
||
|
||
const renderAttachmentGrid = (grid, attachments, onRemove) => {
|
||
if (!grid) return;
|
||
grid.replaceChildren();
|
||
(attachments || []).forEach((attachment, index) => {
|
||
const thumb = document.createElement("div");
|
||
thumb.className = "comment-attachment-thumb";
|
||
const isImage = String(attachment.type || "").startsWith("image/") || /^data:image\//i.test(String(attachment.dataUrl || ""));
|
||
if (isImage) {
|
||
const img = document.createElement("img");
|
||
img.src = attachment.dataUrl;
|
||
img.alt = attachment.name || "attachment";
|
||
thumb.appendChild(img);
|
||
} else {
|
||
const file = document.createElement("div");
|
||
file.className = "comment-attachment-file";
|
||
file.innerHTML = `
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z"></path>
|
||
<path d="M14 2v5h5"></path>
|
||
</svg>
|
||
`;
|
||
const meta = document.createElement("div");
|
||
meta.className = "comment-attachment-file__meta";
|
||
const name = document.createElement("strong");
|
||
name.textContent = attachment.name || "Файл";
|
||
const size = document.createElement("span");
|
||
size.textContent = formatFileSize(attachment.size) || attachment.type || "Файл";
|
||
meta.append(name, size);
|
||
file.appendChild(meta);
|
||
thumb.appendChild(file);
|
||
}
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.setAttribute("aria-label", "Удалить файл");
|
||
remove.textContent = "×";
|
||
remove.addEventListener("click", () => onRemove?.(index));
|
||
thumb.appendChild(remove);
|
||
grid.appendChild(thumb);
|
||
});
|
||
};
|
||
|
||
const renderThreadAttachments = (container, attachments) => {
|
||
if (!attachments?.length) return;
|
||
const grid = document.createElement("div");
|
||
grid.className = "comment-attachment-grid";
|
||
attachments.forEach((attachment) => {
|
||
const thumb = document.createElement("div");
|
||
thumb.className = "comment-attachment-thumb";
|
||
const isImage = String(attachment.type || "").startsWith("image/") || /^data:image\//i.test(String(attachment.dataUrl || ""));
|
||
if (isImage) {
|
||
const img = document.createElement("img");
|
||
img.src = attachment.dataUrl;
|
||
img.alt = attachment.name || "attachment";
|
||
thumb.appendChild(img);
|
||
} else {
|
||
const file = document.createElement("div");
|
||
file.className = "comment-attachment-file";
|
||
file.innerHTML = `
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z"></path>
|
||
<path d="M14 2v5h5"></path>
|
||
</svg>
|
||
`;
|
||
const meta = document.createElement("div");
|
||
meta.className = "comment-attachment-file__meta";
|
||
const name = document.createElement("strong");
|
||
name.textContent = attachment.name || "Файл";
|
||
const size = document.createElement("span");
|
||
size.textContent = formatFileSize(attachment.size) || attachment.type || "Файл";
|
||
meta.append(name, size);
|
||
file.appendChild(meta);
|
||
thumb.appendChild(file);
|
||
}
|
||
grid.appendChild(thumb);
|
||
});
|
||
container.appendChild(grid);
|
||
};
|
||
|
||
const renderCommentThread = (comment) => {
|
||
if (!commentThreadList) return;
|
||
commentThreadList.replaceChildren();
|
||
const messages = Array.isArray(comment?.messages) ? comment.messages : [];
|
||
if (!messages.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "comment-thread-empty";
|
||
empty.textContent = "Комментариев пока нет";
|
||
commentThreadList.appendChild(empty);
|
||
return;
|
||
}
|
||
messages.forEach((message) => {
|
||
const item = document.createElement("div");
|
||
item.className = "comment-thread-message";
|
||
const head = document.createElement("div");
|
||
head.className = "comment-thread-message__head";
|
||
const author = document.createElement("strong");
|
||
author.textContent = message?.createdBy?.name || message?.createdBy?.email || "Пользователь";
|
||
const date = document.createElement("span");
|
||
date.textContent = formatDateTime(message?.updatedAt || message?.createdAt);
|
||
head.append(author, date);
|
||
const body = document.createElement("div");
|
||
body.className = "comment-thread-message__body";
|
||
body.textContent = message?.body || "";
|
||
item.append(head, body);
|
||
renderThreadAttachments(item, message?.attachments || []);
|
||
commentThreadList.appendChild(item);
|
||
});
|
||
};
|
||
|
||
const resetCommentModalAttachments = () => {
|
||
commentDraftAttachments = [];
|
||
commentReplyAttachments = [];
|
||
commentDraftSubitems = [];
|
||
renderModalAttachmentInputs();
|
||
renderCommentSubitems();
|
||
};
|
||
|
||
const renderModalAttachmentInputs = () => {
|
||
renderAttachmentGrid(commentAttachmentGrid, commentDraftAttachments, (index) => {
|
||
commentDraftAttachments.splice(index, 1);
|
||
renderModalAttachmentInputs();
|
||
});
|
||
renderAttachmentGrid(commentReplyAttachmentGrid, commentReplyAttachments, (index) => {
|
||
commentReplyAttachments.splice(index, 1);
|
||
renderModalAttachmentInputs();
|
||
});
|
||
};
|
||
|
||
const setCommentModalExpanded = (expanded) => {
|
||
commentModalExpanded = !!expanded;
|
||
commentModal?.classList.toggle("is-expanded", commentModalExpanded);
|
||
if (commentModalExpandButton) {
|
||
commentModalExpandButton.setAttribute("aria-label", commentModalExpanded ? "Свернуть" : "Развернуть");
|
||
commentModalExpandButton.title = commentModalExpanded ? "Свернуть" : "Развернуть";
|
||
}
|
||
};
|
||
|
||
const hideCommentSubitemMenu = () => {
|
||
commentSubitemMenu?.classList.add("hidden");
|
||
};
|
||
|
||
const showCommentSubitemMenu = () => {
|
||
if (!commentSubitemMenu || !commentAddSubitemButton) return;
|
||
if (commentSubitemMenu.parentElement !== document.body) {
|
||
document.body.appendChild(commentSubitemMenu);
|
||
}
|
||
commentSubitemMenu.classList.remove("hidden");
|
||
const buttonRect = commentAddSubitemButton.getBoundingClientRect();
|
||
const width = commentSubitemMenu.offsetWidth || 276;
|
||
const x = Math.max(8, Math.min(window.innerWidth - width - 8, buttonRect.left));
|
||
const y = buttonRect.bottom + 8;
|
||
commentSubitemMenu.style.left = `${x}px`;
|
||
commentSubitemMenu.style.top = `${y}px`;
|
||
};
|
||
|
||
const toggleCommentSubitemMenu = () => {
|
||
if (!commentSubitemMenu) return;
|
||
if (commentSubitemMenu.classList.contains("hidden")) {
|
||
showCommentSubitemMenu();
|
||
} else {
|
||
hideCommentSubitemMenu();
|
||
}
|
||
};
|
||
|
||
const addCommentSubitem = (kind) => {
|
||
const nextKind = kind === "text" ? "text" : "checker";
|
||
commentDraftSubitems.push({
|
||
id: `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`,
|
||
kind: nextKind,
|
||
title: "",
|
||
body: "",
|
||
done: false,
|
||
});
|
||
renderCommentSubitems();
|
||
window.requestAnimationFrame(() => {
|
||
const selector = nextKind === "text"
|
||
? ".ops-comment-subitem[data-kind='text'] textarea"
|
||
: ".ops-comment-subitem[data-kind='checker'] input[type='text']";
|
||
const inputs = commentSubitemsList?.querySelectorAll?.(selector);
|
||
inputs?.[inputs.length - 1]?.focus?.();
|
||
});
|
||
};
|
||
|
||
const renderCommentSubitems = () => {
|
||
if (!commentSubitemsList || !commentSubitemsSection) return;
|
||
commentSubitemsList.replaceChildren();
|
||
const subitems = Array.isArray(commentDraftSubitems) ? commentDraftSubitems : [];
|
||
commentSubitemsSection.classList.toggle("hidden", !subitems.length);
|
||
subitems.forEach((subitem, index) => {
|
||
const kind = subitem.kind === "text" ? "text" : "checker";
|
||
const row = document.createElement(kind === "text" ? "div" : "label");
|
||
row.className = "ops-comment-subitem";
|
||
row.dataset.kind = kind;
|
||
let editor = null;
|
||
if (kind === "text") {
|
||
editor = document.createElement("textarea");
|
||
editor.rows = 3;
|
||
editor.value = subitem.body || subitem.title || "";
|
||
editor.placeholder = "Текстовый блок";
|
||
editor.addEventListener("input", () => {
|
||
commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, body: editor.value, title: editor.value };
|
||
});
|
||
} else {
|
||
const checkbox = document.createElement("input");
|
||
checkbox.type = "checkbox";
|
||
checkbox.checked = !!subitem.done;
|
||
checkbox.addEventListener("change", () => {
|
||
commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, done: checkbox.checked };
|
||
});
|
||
editor = document.createElement("input");
|
||
editor.type = "text";
|
||
editor.value = subitem.title || subitem.body || "";
|
||
editor.placeholder = "Новый чекер";
|
||
editor.addEventListener("input", () => {
|
||
commentDraftSubitems[index] = { ...commentDraftSubitems[index], kind, title: editor.value };
|
||
});
|
||
row.appendChild(checkbox);
|
||
}
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.setAttribute("aria-label", "Удалить подэлемент");
|
||
remove.textContent = "×";
|
||
remove.addEventListener("click", () => {
|
||
commentDraftSubitems.splice(index, 1);
|
||
renderCommentSubitems();
|
||
});
|
||
row.append(editor, remove);
|
||
commentSubitemsList.appendChild(row);
|
||
});
|
||
};
|
||
|
||
const openCommentModalForTarget = () => {
|
||
if (!commentContextTarget || !commentModal) return;
|
||
hideCommentSubitemMenu();
|
||
commentsModeActive = true;
|
||
commentsCollapsed = false;
|
||
commentModalMode = "create";
|
||
commentModalCommentId = null;
|
||
activeCommentId = "__draft_comment__";
|
||
commentTitleInput && (commentTitleInput.value = "");
|
||
commentBodyInput && (commentBodyInput.value = "");
|
||
commentReplyInput && (commentReplyInput.value = "");
|
||
setCommentModalSubtitle();
|
||
if (commentModalEditedMeta) {
|
||
const [x, y, z] = commentContextTarget.worldPos || [];
|
||
commentModalEditedMeta.textContent = Number.isFinite(x)
|
||
? `Последнее редактирование: новая точка ${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)}`
|
||
: "Последнее редактирование: новый комментарий";
|
||
}
|
||
setCommentModalError("");
|
||
resetCommentModalAttachments();
|
||
renderCommentThread(null);
|
||
commentModalBackdrop?.classList.remove("hidden");
|
||
commentModal.classList.remove("hidden");
|
||
bringFloatingWindowToFront("commentModal");
|
||
updateCommentsVisibility();
|
||
commentTitleInput?.focus();
|
||
};
|
||
|
||
const openCommentModalForComment = (commentId) => {
|
||
const comment = commentRecords.find((item) => item.id === commentId);
|
||
if (!comment || !commentModal) return;
|
||
hideCommentSubitemMenu();
|
||
commentContextTarget = null;
|
||
commentModalMode = "edit";
|
||
commentModalCommentId = comment.id;
|
||
activeCommentId = comment.id;
|
||
commentTitleInput && (commentTitleInput.value = comment.title || "");
|
||
commentBodyInput && (commentBodyInput.value = comment.body || "");
|
||
commentReplyInput && (commentReplyInput.value = "");
|
||
setCommentModalSubtitle();
|
||
if (commentModalEditedMeta) {
|
||
commentModalEditedMeta.textContent = `Последнее редактирование: ${formatDateTime(comment.updatedAt || comment.createdAt)}`;
|
||
}
|
||
setCommentModalError("");
|
||
resetCommentModalAttachments();
|
||
commentDraftSubitems = Array.isArray(comment.subitems)
|
||
? comment.subitems.map((item) => ({ ...item }))
|
||
: [];
|
||
renderCommentSubitems();
|
||
renderCommentThread(comment);
|
||
commentModalBackdrop?.classList.remove("hidden");
|
||
commentModal.classList.remove("hidden");
|
||
commentsModeActive = true;
|
||
commentsCollapsed = false;
|
||
bringFloatingWindowToFront("commentModal");
|
||
updateCommentsVisibility();
|
||
commentTitleInput?.focus();
|
||
renderCommentsPanel();
|
||
renderCommentTargets();
|
||
};
|
||
|
||
const closeCommentModal = () => {
|
||
hideCommentSubitemMenu();
|
||
commentModalBackdrop?.classList.add("hidden");
|
||
commentModal?.classList.add("hidden");
|
||
setCommentModalExpanded(false);
|
||
if (commentModalMode === "create") {
|
||
commentContextTarget = null;
|
||
}
|
||
activeCommentId = null;
|
||
commentModalMode = "create";
|
||
commentModalCommentId = null;
|
||
setCommentModalError("");
|
||
renderCommentsPanel();
|
||
renderCommentTargets();
|
||
};
|
||
|
||
const normalizedSubitemsDraft = () => (
|
||
(commentDraftSubitems || [])
|
||
.map((item) => {
|
||
const kind = item.kind === "text" ? "text" : "checker";
|
||
const content = String(kind === "text" ? (item.body || item.title || "") : (item.title || item.body || "")).trim();
|
||
return {
|
||
id: item.id || `sub_${Date.now()}_${Math.random().toString(36).slice(2)}`,
|
||
kind,
|
||
title: content,
|
||
body: kind === "text" ? content : "",
|
||
done: kind === "checker" && item.done === true,
|
||
};
|
||
})
|
||
.filter((item) => item.title || item.body)
|
||
);
|
||
|
||
const buildCommentPayload = ({ title, body, replyBody } = {}) => {
|
||
const finalBody = body || replyBody || "";
|
||
return {
|
||
...(commentContextTarget || {}),
|
||
title: title || (finalBody ? finalBody.slice(0, 80) : "Комментарий"),
|
||
body: finalBody,
|
||
attachments: [...commentDraftAttachments, ...commentReplyAttachments].slice(0, 8),
|
||
subitems: normalizedSubitemsDraft(),
|
||
};
|
||
};
|
||
|
||
const syncCommentModalAfterSave = (comment) => {
|
||
if (!comment) return;
|
||
commentModalMode = "edit";
|
||
commentModalCommentId = comment.id;
|
||
commentContextTarget = null;
|
||
activeCommentId = comment.id;
|
||
commentTitleInput && (commentTitleInput.value = comment.title || "");
|
||
commentBodyInput && (commentBodyInput.value = comment.body || "");
|
||
commentReplyInput && (commentReplyInput.value = "");
|
||
commentDraftAttachments = [];
|
||
commentReplyAttachments = [];
|
||
commentDraftSubitems = Array.isArray(comment.subitems)
|
||
? comment.subitems.map((item) => ({ ...item }))
|
||
: [];
|
||
setCommentModalSubtitle();
|
||
if (commentModalEditedMeta) {
|
||
commentModalEditedMeta.textContent = `Последнее редактирование: ${formatDateTime(comment.updatedAt || comment.createdAt)}`;
|
||
}
|
||
renderModalAttachmentInputs();
|
||
renderCommentSubitems();
|
||
renderCommentThread(comment);
|
||
};
|
||
|
||
const applyCommentModal = async ({ closeOnSave = false } = {}) => {
|
||
if (isGuestMode()) return;
|
||
if (commentSaveInFlight) return;
|
||
const title = commentTitleInput?.value?.trim() || "";
|
||
const body = commentBodyInput?.value?.trim() || "";
|
||
const replyBody = commentReplyInput?.value?.trim() || "";
|
||
setCommentModalError("");
|
||
if (commentModalMode === "create" && !commentContextTarget?.worldPos) {
|
||
setCommentModalError("Не выбрана точка комментария");
|
||
return;
|
||
}
|
||
commentSaveInFlight = true;
|
||
if (commentApplyButton) {
|
||
commentApplyButton.disabled = true;
|
||
commentApplyButton.textContent = "Сохранение...";
|
||
}
|
||
if (commentSaveButton) {
|
||
commentSaveButton.disabled = true;
|
||
commentSaveButton.textContent = "Сохранение...";
|
||
}
|
||
if (commentApplyInlineButton) {
|
||
commentApplyInlineButton.disabled = true;
|
||
}
|
||
try {
|
||
let payload = null;
|
||
if (commentModalMode === "create") {
|
||
payload = await fetchJSON(COMMENTS_API, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(buildCommentPayload({ title, body, replyBody })),
|
||
});
|
||
} else if (commentModalCommentId) {
|
||
const original = commentRecords.find((item) => item.id === commentModalCommentId);
|
||
const subitems = normalizedSubitemsDraft();
|
||
const messageAttachments = [...commentDraftAttachments, ...commentReplyAttachments].slice(0, 8);
|
||
const patchPayload = {};
|
||
if (!original || title !== (original.title || "")) patchPayload.title = title;
|
||
if (!original || body !== (original.body || "")) patchPayload.body = body;
|
||
if (JSON.stringify(subitems) !== JSON.stringify(original?.subitems || [])) patchPayload.subitems = subitems;
|
||
if (replyBody || messageAttachments.length) {
|
||
patchPayload.message = {
|
||
body: replyBody,
|
||
attachments: messageAttachments,
|
||
};
|
||
}
|
||
if (!Object.keys(patchPayload).length) {
|
||
if (closeOnSave) {
|
||
closeCommentModal();
|
||
} else {
|
||
setStatus("Изменений нет");
|
||
}
|
||
return;
|
||
}
|
||
payload = await fetchJSON(`${COMMENTS_API}/${encodeURIComponent(commentModalCommentId)}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(patchPayload),
|
||
});
|
||
}
|
||
const saved = payload?.comment;
|
||
if (saved) {
|
||
mergeCommentRecords([saved]);
|
||
activeCommentId = saved.id;
|
||
commentContextTarget = null;
|
||
commentsModeActive = true;
|
||
commentsCollapsed = false;
|
||
syncCommentModalAfterSave(saved);
|
||
if (closeOnSave) {
|
||
closeCommentModal();
|
||
}
|
||
updateCommentsVisibility();
|
||
setStatus("Комментарий сохранён");
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
const message = err.message || "Не удалось сохранить комментарий";
|
||
setCommentModalError(message);
|
||
setStatus(message);
|
||
} finally {
|
||
commentSaveInFlight = false;
|
||
if (commentApplyButton) {
|
||
commentApplyButton.disabled = false;
|
||
commentApplyButton.textContent = "Применить";
|
||
}
|
||
if (commentSaveButton) {
|
||
commentSaveButton.disabled = false;
|
||
commentSaveButton.textContent = "Сохранить";
|
||
}
|
||
if (commentApplyInlineButton) {
|
||
commentApplyInlineButton.disabled = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const setupDraggableFixedWindow = (element, handle) => {
|
||
if (!element || !handle) return;
|
||
let drag = null;
|
||
handle.addEventListener("pointerdown", (event) => {
|
||
if (event.button !== 0 || event.target.closest("button,input,textarea")) return;
|
||
const rect = element.getBoundingClientRect();
|
||
drag = {
|
||
pointerId: event.pointerId,
|
||
dx: event.clientX - rect.left,
|
||
dy: event.clientY - rect.top,
|
||
};
|
||
element.style.left = `${rect.left}px`;
|
||
element.style.top = `${rect.top}px`;
|
||
element.style.right = "auto";
|
||
element.style.bottom = "auto";
|
||
element.style.transform = "none";
|
||
handle.setPointerCapture?.(event.pointerId);
|
||
event.preventDefault();
|
||
});
|
||
handle.addEventListener("pointermove", (event) => {
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
const width = element.offsetWidth || 320;
|
||
const height = element.offsetHeight || 320;
|
||
const left = Math.max(8, Math.min(window.innerWidth - width - 8, event.clientX - drag.dx));
|
||
const top = Math.max(8, Math.min(window.innerHeight - Math.min(height, window.innerHeight - 16) - 8, event.clientY - drag.dy));
|
||
element.style.left = `${left}px`;
|
||
element.style.top = `${top}px`;
|
||
});
|
||
const endDrag = (event) => {
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
handle.releasePointerCapture?.(event.pointerId);
|
||
drag = null;
|
||
};
|
||
handle.addEventListener("pointerup", endDrag);
|
||
handle.addEventListener("pointercancel", endDrag);
|
||
};
|
||
|
||
const setupResizableFixedWindow = (element, { minWidth = 520, minHeight = 420, margin = 8 } = {}) => {
|
||
if (!element || element.dataset.resizableReady === "true") return;
|
||
element.dataset.resizableReady = "true";
|
||
const directions = ["n", "e", "s", "w", "ne", "nw", "se", "sw"];
|
||
let resize = null;
|
||
|
||
const fixCurrentRect = () => {
|
||
const rect = element.getBoundingClientRect();
|
||
element.style.left = `${rect.left}px`;
|
||
element.style.top = `${rect.top}px`;
|
||
element.style.right = "auto";
|
||
element.style.bottom = "auto";
|
||
element.style.width = `${rect.width}px`;
|
||
element.style.height = `${rect.height}px`;
|
||
element.style.maxHeight = "none";
|
||
element.style.transform = "none";
|
||
return rect;
|
||
};
|
||
|
||
const beginResize = (direction, pointerId, clientX, clientY) => {
|
||
bringFloatingWindowToFront(element.id === "commentModal" ? "commentModal" : element.id);
|
||
const rect = fixCurrentRect();
|
||
resize = {
|
||
pointerId,
|
||
direction,
|
||
startX: clientX,
|
||
startY: clientY,
|
||
left: rect.left,
|
||
top: rect.top,
|
||
width: rect.width,
|
||
height: rect.height,
|
||
};
|
||
};
|
||
|
||
const updateResize = (clientX, clientY) => {
|
||
if (!resize) return;
|
||
const dx = clientX - resize.startX;
|
||
const dy = clientY - resize.startY;
|
||
const right = resize.left + resize.width;
|
||
const bottom = resize.top + resize.height;
|
||
let left = resize.left;
|
||
let top = resize.top;
|
||
let width = resize.width;
|
||
let height = resize.height;
|
||
|
||
if (resize.direction.includes("w")) {
|
||
left = Math.max(margin, Math.min(resize.left + dx, right - minWidth));
|
||
width = right - left;
|
||
}
|
||
if (resize.direction.includes("e")) {
|
||
width = Math.max(minWidth, resize.width + dx);
|
||
width = Math.min(width, window.innerWidth - margin - left);
|
||
}
|
||
if (resize.direction.includes("n")) {
|
||
top = Math.max(margin, Math.min(resize.top + dy, bottom - minHeight));
|
||
height = bottom - top;
|
||
}
|
||
if (resize.direction.includes("s")) {
|
||
height = Math.max(minHeight, resize.height + dy);
|
||
height = Math.min(height, window.innerHeight - margin - top);
|
||
}
|
||
|
||
element.style.left = `${Math.round(left)}px`;
|
||
element.style.top = `${Math.round(top)}px`;
|
||
element.style.width = `${Math.round(width)}px`;
|
||
element.style.height = `${Math.round(height)}px`;
|
||
};
|
||
|
||
directions.forEach((direction) => {
|
||
const handle = document.createElement("span");
|
||
handle.className = `window-resize-handle window-resize-handle--${direction}`;
|
||
handle.dataset.resizeDirection = direction;
|
||
handle.setAttribute("aria-hidden", "true");
|
||
element.appendChild(handle);
|
||
handle.addEventListener("pointerdown", (event) => {
|
||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||
if (element.classList.contains("is-expanded")) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
beginResize(direction, event.pointerId, event.clientX, event.clientY);
|
||
handle.setPointerCapture?.(event.pointerId);
|
||
});
|
||
handle.addEventListener("pointermove", (event) => {
|
||
if (!resize || resize.pointerId !== event.pointerId) return;
|
||
updateResize(event.clientX, event.clientY);
|
||
});
|
||
handle.addEventListener("mousedown", (event) => {
|
||
if (event.button !== 0 || element.classList.contains("is-expanded")) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
beginResize(direction, "mouse", event.clientX, event.clientY);
|
||
});
|
||
const endResize = (event) => {
|
||
if (!resize || resize.pointerId !== event.pointerId) return;
|
||
handle.releasePointerCapture?.(event.pointerId);
|
||
resize = null;
|
||
};
|
||
handle.addEventListener("pointerup", endResize);
|
||
handle.addEventListener("pointercancel", endResize);
|
||
});
|
||
window.addEventListener("mousemove", (event) => {
|
||
if (!resize || resize.pointerId !== "mouse") return;
|
||
updateResize(event.clientX, event.clientY);
|
||
});
|
||
window.addEventListener("mouseup", () => {
|
||
if (resize?.pointerId === "mouse") resize = null;
|
||
});
|
||
};
|
||
|
||
const bindFooterButtonAction = (button, handler) => {
|
||
if (!button || typeof handler !== "function") return;
|
||
button.addEventListener("click", handler);
|
||
button.addEventListener("pointerup", (event) => {
|
||
const isPrimaryPointer = event.pointerType !== "mouse" || event.button === 0;
|
||
if (!isPrimaryPointer || button.disabled) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
handler(event);
|
||
}, true);
|
||
};
|
||
|
||
const applyRuntimeMode = () => {
|
||
const guest = isGuestMode();
|
||
document.body.dataset.viewerMode = viewerMode;
|
||
if (bottomToolbar) {
|
||
bottomToolbar.classList.remove("hidden");
|
||
bottomToolbar.querySelectorAll(".tb-btn").forEach((btn) => {
|
||
const action = btn.dataset.action || "";
|
||
const guestHidden = guest && !isGuestToolbarAction(action);
|
||
btn.classList.toggle("hidden", guestHidden);
|
||
if (guestHidden) {
|
||
btn.disabled = true;
|
||
} else if (!guest && action !== "section" && action !== "comments" && action !== "share") {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
if (settingsButton) settingsButton.disabled = guest;
|
||
if (templatesButton) templatesButton.disabled = guest;
|
||
if (measureButton && guest) measureButton.disabled = !measurePlugin || !measureControl || activeModels.length === 0;
|
||
if (projectionToggle && guest) projectionToggle.disabled = activeModels.length === 0;
|
||
if (commentsButton) {
|
||
commentsButton.classList.toggle("hidden", guest);
|
||
commentsButton.disabled = guest || activeModels.length === 0;
|
||
}
|
||
if (guest) {
|
||
closeSettingsPanel();
|
||
closeTemplatesPanel();
|
||
settingsPanel?.classList.add("hidden");
|
||
templatesPanel?.classList.add("hidden");
|
||
commentsModeActive = false;
|
||
commentsCollapsed = false;
|
||
commentContextTarget = null;
|
||
activeCommentId = null;
|
||
hoverCommentId = null;
|
||
commentsPanel?.classList.add("hidden");
|
||
if (commentsRestoreTray) commentsRestoreTray.hidden = true;
|
||
commentTargetsLayer?.classList.add("hidden");
|
||
hideCommentContextMenu();
|
||
stopCommentTargetLoop();
|
||
}
|
||
if (shareButton) {
|
||
const canShowShare = !guest && runtimeSessionInfo.canShare && activeModels.length > 0;
|
||
shareButton.classList.toggle("hidden", !canShowShare);
|
||
shareButton.disabled = !canShowShare;
|
||
}
|
||
if (dropZone) {
|
||
dropZone.style.pointerEvents = guest ? "none" : "";
|
||
}
|
||
};
|
||
|
||
const initializeRuntimeMode = async () => {
|
||
const shareToken = getShareTokenFromPath();
|
||
const shareGuestAuthMarker = hasShareGuestAuthMarker();
|
||
if (shareToken) {
|
||
viewerMode = "guest";
|
||
inspectorCollapsed = true;
|
||
try {
|
||
const response = await fetch(`${SHARES_API}/${encodeURIComponent(shareToken)}`, {
|
||
credentials: "include",
|
||
headers: { "Accept": "application/json" },
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`Share link failed: HTTP ${response.status}`);
|
||
}
|
||
startupSharePayload = await response.json();
|
||
} catch (err) {
|
||
console.error(err);
|
||
showError("Ссылка на модель недоступна");
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(AUTH_SESSION_API, {
|
||
credentials: "include",
|
||
headers: { "Accept": "application/json" },
|
||
});
|
||
if (response.ok) {
|
||
runtimeSessionInfo = await response.json();
|
||
}
|
||
} catch (err) {
|
||
console.warn("BIM session status unavailable", err);
|
||
}
|
||
|
||
if (shareToken) {
|
||
const authenticatedShare = runtimeSessionInfo.authenticated === true ||
|
||
startupSharePayload?.authenticated === true ||
|
||
startupSharePayload?.mode === "standard" ||
|
||
startupSharePayload?.readonly === false;
|
||
viewerMode = authenticatedShare ? "standard" : "guest";
|
||
}
|
||
|
||
applyRuntimeMode();
|
||
clearShareGuestAuthMarker();
|
||
if (shareToken && viewerMode === "guest" && !shareGuestAuthMarker) {
|
||
scheduleShareSessionPromotion(shareToken);
|
||
}
|
||
};
|
||
|
||
const getShareableModelPayload = () => {
|
||
const entry = getPrimaryModelEntry();
|
||
if (!entry) {
|
||
return null;
|
||
}
|
||
const src = normalizeModelSettingsSrc(entry.meta?.artifactSrc || entry.src || entry.meta?.src);
|
||
const settingsSrc = getModelSettingsSrc(entry);
|
||
if (!src) {
|
||
return null;
|
||
}
|
||
const versionIdentity = getModelVersionIdentity(entry);
|
||
return {
|
||
assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || null,
|
||
projectId: entry.meta?.projectId || entry.meta?.conversion?.projectId || versionIdentity.projectId || null,
|
||
name: entry.name || entry.meta?.label || src.split("/").pop() || "model",
|
||
settingsSrc: settingsSrc || src,
|
||
src,
|
||
type: entry.type || normalizeType(null, src),
|
||
versionId: entry.meta?.versionId || entry.meta?.conversion?.versionId || null,
|
||
versions: Array.isArray(entry.meta?.versions) ? entry.meta.versions : [],
|
||
};
|
||
};
|
||
|
||
const closeShareModal = () => {
|
||
shareModal?.classList.add("hidden");
|
||
shareModalBackdrop?.classList.add("hidden");
|
||
if (shareError) shareError.textContent = "";
|
||
};
|
||
|
||
const openShareModal = async () => {
|
||
if (isGuestMode()) return;
|
||
const payload = getShareableModelPayload();
|
||
if (!payload) {
|
||
showError("Ссылку можно создать только для модели из BIM-хранилища");
|
||
return;
|
||
}
|
||
shareModal?.classList.remove("hidden");
|
||
shareModalBackdrop?.classList.remove("hidden");
|
||
if (shareLinkInput) shareLinkInput.value = "Генерируем ссылку...";
|
||
if (shareCopyButton) shareCopyButton.disabled = true;
|
||
if (shareError) shareError.textContent = "";
|
||
|
||
try {
|
||
const response = await fetch(SHARES_API, {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: {
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const data = await response.json().catch(() => null);
|
||
if (!response.ok || !data?.url) {
|
||
throw new Error(data?.error || `Share create failed: HTTP ${response.status}`);
|
||
}
|
||
if (shareLinkInput) {
|
||
shareLinkInput.value = data.url;
|
||
shareLinkInput.select();
|
||
}
|
||
if (shareCopyButton) shareCopyButton.disabled = false;
|
||
} catch (err) {
|
||
console.error(err);
|
||
if (shareLinkInput) shareLinkInput.value = "";
|
||
if (shareError) shareError.textContent = err?.message || "Не удалось создать ссылку";
|
||
}
|
||
};
|
||
|
||
const copyShareLink = async () => {
|
||
const value = shareLinkInput?.value || "";
|
||
if (!value) return;
|
||
try {
|
||
await navigator.clipboard?.writeText(value);
|
||
} catch (_) {
|
||
shareLinkInput?.select();
|
||
document.execCommand?.("copy");
|
||
}
|
||
};
|
||
|
||
const getDisplayModePreset = (mode) => {
|
||
switch (mode) {
|
||
case "clay":
|
||
return { colorize: [0.76, 0.35, 0.25], opacity: 1, edges: false, xrayed: false };
|
||
case "white":
|
||
return { colorize: [1, 1, 1], opacity: 1, edges: false, xrayed: false };
|
||
case "custom":
|
||
return { colorize: getCustomDisplayColorize(), opacity: 1, edges: false, xrayed: false };
|
||
case "edges":
|
||
return { colorize: null, opacity: 1, edges: true, xrayed: false };
|
||
case "xray":
|
||
return { colorize: [0.72, 0.9, 1], opacity: 1, edges: true, xrayed: true };
|
||
case "source":
|
||
default:
|
||
return { colorize: null, opacity: 1, edges: "base", xrayed: false };
|
||
}
|
||
};
|
||
|
||
const getBaseEdgesForModel = (modelId) => {
|
||
const modelType = modelTypes[modelId];
|
||
if (modelType === "las") return false;
|
||
if (modelType === "gltf") return false;
|
||
return !!toggleEdges?.checked;
|
||
};
|
||
|
||
const isPointCloudModel = (modelId) => modelTypes[modelId] === "las";
|
||
|
||
const getPointCloudLayers = (model) => (
|
||
(model?.layerList || []).filter((layer) => (
|
||
layer?.primitive === "points" &&
|
||
layer?._state?.colorsBuf?.getData &&
|
||
layer?._state?.colorsBuf?.setData
|
||
))
|
||
);
|
||
|
||
const snapshotPointCloudColors = (modelId) => {
|
||
const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId];
|
||
if (!model || !isPointCloudModel(modelId) || pointCloudColorSnapshots.has(modelId)) return;
|
||
const snapshots = getPointCloudLayers(model)
|
||
.map((layer) => {
|
||
try {
|
||
const colors = layer._state.colorsBuf.getData();
|
||
return colors?.length ? { layer, colors, workingColors: null } : null;
|
||
} catch (err) {
|
||
console.warn("point cloud color snapshot failed", err);
|
||
return null;
|
||
}
|
||
})
|
||
.filter(Boolean);
|
||
if (snapshots.length) {
|
||
pointCloudColorSnapshots.set(modelId, snapshots);
|
||
}
|
||
};
|
||
|
||
const clearPointCloudEntityColorState = (targets) => {
|
||
targets.forEach((entity) => {
|
||
if (!entity) return;
|
||
try {
|
||
entity.xrayed = false;
|
||
entity.opacity = 1;
|
||
entity.edges = false;
|
||
(entity.meshes || []).forEach((mesh) => {
|
||
mesh._colorizing = false;
|
||
});
|
||
if (entity._colorizeUpdated && viewer?.scene?._objectColorizeUpdated) {
|
||
viewer.scene._objectColorizeUpdated(entity, false);
|
||
entity._colorizeUpdated = false;
|
||
}
|
||
} catch (err) {
|
||
console.warn("point cloud entity reset failed", err);
|
||
}
|
||
});
|
||
};
|
||
|
||
const writePointCloudColors = (modelId, colorize = null) => {
|
||
snapshotPointCloudColors(modelId);
|
||
const snapshots = pointCloudColorSnapshots.get(modelId) || [];
|
||
const color = colorize
|
||
? colorize.map((v) => Math.max(0, Math.min(255, Math.round(v * 255))))
|
||
: null;
|
||
|
||
snapshots.forEach((snapshot) => {
|
||
const colorsBuf = snapshot.layer?._state?.colorsBuf;
|
||
if (!colorsBuf?.setData) return;
|
||
try {
|
||
if (!color) {
|
||
colorsBuf.setData(snapshot.colors);
|
||
return;
|
||
}
|
||
const original = snapshot.colors;
|
||
const working = snapshot.workingColors?.length === original.length
|
||
? snapshot.workingColors
|
||
: new Uint8Array(original.length);
|
||
for (let i = 0; i < original.length; i += 4) {
|
||
working[i] = color[0];
|
||
working[i + 1] = color[1];
|
||
working[i + 2] = color[2];
|
||
working[i + 3] = original[i + 3];
|
||
}
|
||
snapshot.workingColors = working;
|
||
colorsBuf.setData(working);
|
||
} catch (err) {
|
||
console.warn("point cloud color restore failed", err);
|
||
}
|
||
});
|
||
};
|
||
|
||
const applyPointCloudDisplayMode = (modelId, preset, targets) => {
|
||
clearPointCloudEntityColorState(targets);
|
||
writePointCloudColors(modelId, preset.colorize);
|
||
viewer?.scene?.glRedraw?.();
|
||
};
|
||
|
||
const applyDisplayModeToModel = (modelId) => {
|
||
const model = sceneModels[modelId] || viewer?.scene?.models?.[modelId];
|
||
if (!model) return;
|
||
const preset = getDisplayModePreset(normalizeDisplayMode(activeDisplayMode));
|
||
const objects = Object.values(model.objects || {});
|
||
const targets = objects.length ? objects : [model];
|
||
|
||
if (isPointCloudModel(modelId)) {
|
||
applyPointCloudDisplayMode(modelId, preset, targets);
|
||
return;
|
||
}
|
||
|
||
targets.forEach((entity) => {
|
||
if (!entity) return;
|
||
try {
|
||
entity.xrayed = !!preset.xrayed;
|
||
entity.colorize = preset.colorize;
|
||
entity.opacity = preset.opacity;
|
||
entity.edges = modelTypes[modelId] === "las"
|
||
? false
|
||
: (preset.edges === "base" ? getBaseEdgesForModel(modelId) : !!preset.edges);
|
||
} catch (err) {
|
||
console.warn("display mode update failed", err);
|
||
}
|
||
});
|
||
|
||
viewer?.scene?.glRedraw?.();
|
||
};
|
||
|
||
const applyDisplayMode = () => {
|
||
setDisplayModeControlState();
|
||
Object.keys(sceneModels).forEach((modelId) => applyDisplayModeToModel(modelId));
|
||
};
|
||
|
||
const createSceneLight = (type, cfg) => {
|
||
if (!viewer?.scene || !sdkRef) return null;
|
||
if (type === "ambient" && sdkRef.AmbientLight) {
|
||
return new sdkRef.AmbientLight(viewer.scene, cfg);
|
||
}
|
||
if (type === "dir" && sdkRef.DirLight) {
|
||
return new sdkRef.DirLight(viewer.scene, cfg);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const resetSceneLighting = () => {
|
||
if (!viewer?.scene) return;
|
||
viewer.scene.clearLights?.();
|
||
};
|
||
|
||
const applyLightingMode = ({ announce = false } = {}) => {
|
||
activeLightingMode = normalizeLightingMode(activeLightingMode);
|
||
setLightingModeControlState();
|
||
if (!viewer?.scene || !sdkRef) return;
|
||
|
||
const scene = viewer.scene;
|
||
resetSceneLighting();
|
||
scene.pbrEnabled = false;
|
||
scene.colorTextureEnabled = true;
|
||
if (scene.sao) {
|
||
scene.sao.enabled = false;
|
||
}
|
||
|
||
switch (activeLightingMode) {
|
||
case "standard":
|
||
createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.7 });
|
||
createSceneLight("dir", {
|
||
dir: [0.8, -0.5, -0.5],
|
||
color: [0.67, 0.67, 1.0],
|
||
intensity: 0.7,
|
||
space: "world",
|
||
});
|
||
createSceneLight("dir", {
|
||
dir: [-0.8, -1.0, 0.5],
|
||
color: [1.0, 1.0, 0.9],
|
||
intensity: 0.9,
|
||
space: "world",
|
||
});
|
||
break;
|
||
case "soft":
|
||
createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.82 });
|
||
createSceneLight("dir", {
|
||
dir: [0.7, -0.65, -0.55],
|
||
color: [1.0, 1.0, 1.0],
|
||
intensity: 0.22,
|
||
space: "view",
|
||
});
|
||
createSceneLight("dir", {
|
||
dir: [-0.6, -0.35, 0.7],
|
||
color: [0.82, 0.88, 1.0],
|
||
intensity: 0.16,
|
||
space: "view",
|
||
});
|
||
break;
|
||
case "technical":
|
||
default:
|
||
createSceneLight("ambient", { color: [1.0, 1.0, 1.0], intensity: 0.62 });
|
||
createSceneLight("dir", {
|
||
dir: [0.9, -0.45, -0.35],
|
||
color: [0.95, 0.98, 1.0],
|
||
intensity: 0.34,
|
||
space: "view",
|
||
});
|
||
createSceneLight("dir", {
|
||
dir: [-0.55, -0.75, 0.45],
|
||
color: [1.0, 0.96, 0.88],
|
||
intensity: 0.24,
|
||
space: "view",
|
||
});
|
||
if (scene.sao) {
|
||
scene.sao.enabled = true;
|
||
scene.sao.intensity = 0.08;
|
||
scene.sao.bias = 0.7;
|
||
scene.sao.kernelRadius = 48;
|
||
scene.sao.numSamples = 20;
|
||
}
|
||
break;
|
||
}
|
||
|
||
scene.glRedraw?.();
|
||
if (announce) {
|
||
const label = LIGHTING_MODE_OPTIONS.find((option) => option.value === activeLightingMode)?.label || "Свет";
|
||
setStatus(`Свет: ${label}`);
|
||
}
|
||
};
|
||
|
||
const cloneVec3 = (vec, fallback = [0, 0, 0]) => (
|
||
Array.isArray(vec) || ArrayBuffer.isView(vec) ? Array.from(vec).slice(0, 3) : [...fallback]
|
||
);
|
||
|
||
const normalizeVec3 = (vec, fallback = [0, 0, 1]) => {
|
||
const [x, y, z] = cloneVec3(vec, fallback);
|
||
const length = Math.hypot(x, y, z);
|
||
return length > 0.00001 ? [x / length, y / length, z / length] : [...fallback];
|
||
};
|
||
|
||
const dotVec3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||
|
||
const getNavigationAxisPreset = (axis) => {
|
||
if (axis === "auto") {
|
||
const worldAxis = defaultCameraWorldAxis || NAVIGATION_AXIS_PRESETS["y-up"].worldAxis;
|
||
return {
|
||
worldAxis,
|
||
up: defaultCameraUp || NAVIGATION_AXIS_PRESETS["y-up"].up,
|
||
forward: worldAxis.length >= 9 ? worldAxis.slice(6, 9) : NAVIGATION_AXIS_PRESETS["y-up"].forward,
|
||
};
|
||
}
|
||
return NAVIGATION_AXIS_PRESETS[axis] || NAVIGATION_AXIS_PRESETS["y-up"];
|
||
};
|
||
|
||
const applyNavigationAxis = ({ resetAuto = true, announce = false } = {}) => {
|
||
activeNavigationAxis = normalizeNavigationAxis(activeNavigationAxis);
|
||
setNavigationAxisControlState();
|
||
if (!viewer?.camera) return;
|
||
if (activeNavigationAxis === "auto" && !resetAuto) return;
|
||
|
||
const preset = getNavigationAxisPreset(activeNavigationAxis);
|
||
const camera = viewer.camera;
|
||
const up = normalizeVec3(preset.up, [0, 1, 0]);
|
||
const eye = cloneVec3(camera.eye, [0, 0, 10]);
|
||
const look = cloneVec3(camera.look, [0, 0, 0]);
|
||
const eyeVector = [eye[0] - look[0], eye[1] - look[1], eye[2] - look[2]];
|
||
const viewDirection = normalizeVec3(eyeVector, preset.forward);
|
||
const distance = Math.hypot(eyeVector[0], eyeVector[1], eyeVector[2]) || 10;
|
||
|
||
camera.worldAxis = [...preset.worldAxis];
|
||
camera.gimbalLock = true;
|
||
|
||
if (Math.abs(dotVec3(viewDirection, up)) > 0.96) {
|
||
const forward = normalizeVec3(preset.forward, [0, 0, 1]);
|
||
camera.eye = [
|
||
look[0] + forward[0] * distance,
|
||
look[1] + forward[1] * distance,
|
||
look[2] + forward[2] * distance,
|
||
];
|
||
}
|
||
camera.up = [...up];
|
||
viewer.scene?.glRedraw?.();
|
||
rearmCameraControl();
|
||
|
||
if (announce) {
|
||
const label = NAVIGATION_AXIS_OPTIONS.find((option) => option.value === activeNavigationAxis)?.label || "Авто";
|
||
setStatus(`Ось навигации: ${label}`);
|
||
}
|
||
};
|
||
|
||
const toggleProjection = () => {
|
||
if (!viewer?.camera) return;
|
||
const cam = viewer.camera;
|
||
const next = cam.projection === "ortho" ? "perspective" : "ortho";
|
||
const eye = cam.eye ? [...cam.eye] : [0, 0, 10];
|
||
const look = cam.look ? [...cam.look] : [0, 0, 0];
|
||
const dx = eye[0] - look[0];
|
||
const dy = eye[1] - look[1];
|
||
const dz = eye[2] - look[2];
|
||
const dist = Math.hypot(dx, dy, dz) || 1;
|
||
const dir = dist > 0 ? [dx / dist, dy / dist, dz / dist] : [0, 0, 1];
|
||
const fovDeg = cam.perspective?.fov || 60;
|
||
const fovRad = (fovDeg * Math.PI) / 180;
|
||
const boundary = viewer.scene?.canvas?.boundary;
|
||
const canvas = viewer.scene?.canvas?.canvas;
|
||
const width = (boundary && boundary[2]) || canvas?.offsetWidth || canvas?.clientWidth || 1;
|
||
const height = (boundary && boundary[3]) || canvas?.offsetHeight || canvas?.clientHeight || 1;
|
||
const aspect = width > 0 && height > 0 ? width / height : 1;
|
||
|
||
if (next === "ortho") {
|
||
const perspHeight = 2 * dist * Math.tan(fovRad / 2);
|
||
const perspWidth = perspHeight * aspect;
|
||
const scale = aspect >= 1 ? perspWidth : perspHeight;
|
||
if (cam.ortho) cam.ortho.scale = scale;
|
||
cam.projection = "ortho";
|
||
} else {
|
||
const scale = cam.ortho?.scale || 1;
|
||
const orthoHeight = aspect >= 1 ? scale / aspect : scale;
|
||
const targetDist = (orthoHeight / 2) / Math.tan(fovRad / 2);
|
||
cam.eye = [look[0] + dir[0] * targetDist, look[1] + dir[1] * targetDist, look[2] + dir[2] * targetDist];
|
||
cam.projection = "perspective";
|
||
}
|
||
updateProjectionButton();
|
||
setStatus(next === "ortho" ? "Аксонометрия" : "Перспектива");
|
||
};
|
||
|
||
const closeSettingsPanel = () => {
|
||
settingsMenu?.hide();
|
||
setToolbarButtonActive(settingsButton, false);
|
||
};
|
||
|
||
const closeTemplatesPanel = () => {
|
||
templatesMenu?.hide();
|
||
setToolbarButtonActive(templatesButton, false);
|
||
};
|
||
|
||
const openSettingsPanel = () => {
|
||
closeTemplatesPanel();
|
||
settingsMenu?.show();
|
||
bringFloatingWindowToFront("settings");
|
||
setToolbarButtonActive(settingsButton, true);
|
||
};
|
||
|
||
const openTemplatesPanel = () => {
|
||
closeSettingsPanel();
|
||
templatesMenu?.show();
|
||
bringFloatingWindowToFront("templates");
|
||
setToolbarButtonActive(templatesButton, true);
|
||
};
|
||
|
||
const cssValue = (name, fallback = "") => {
|
||
const styles = getComputedStyle(document.documentElement);
|
||
const v = styles.getPropertyValue(name);
|
||
return (v && v.trim()) || fallback;
|
||
};
|
||
|
||
const clampNumber = (value, min, max, fallback) => {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) return fallback;
|
||
return Math.max(min, Math.min(max, num));
|
||
};
|
||
|
||
let toolbarDockHideTimer = null;
|
||
let toolbarDockAnimationFrame = null;
|
||
let toolbarDockLastTs = 0;
|
||
const toolbarDockState = new WeakMap();
|
||
const TOOLBAR_DOCK_BASE_SCALE = 2.25;
|
||
const TOOLBAR_DOCK_BASE_DISTANCE = 110;
|
||
const TOOLBAR_DOCK_BASE_NUDGE = 40;
|
||
const TOOLBAR_DOCK_NUDGE_TIGHTNESS = 0.82;
|
||
const TOOLBAR_DOCK_SPRING = {
|
||
mass: 0.1,
|
||
stiffness: 170,
|
||
damping: 12,
|
||
};
|
||
const TOOLBAR_DOCK_MAX_STEP = 1 / 240;
|
||
|
||
const getVisibleToolbarButtons = () => {
|
||
if (!bottomToolbar) return [];
|
||
return Array.from(bottomToolbar.querySelectorAll(".tb-btn")).filter((btn) => {
|
||
if (btn.hidden || btn.classList.contains("hidden")) return false;
|
||
return getComputedStyle(btn).display !== "none";
|
||
});
|
||
};
|
||
|
||
const readToolbarDockSettings = () => {
|
||
const minSize = clampNumber(parseFloat(cssValue("--tb-min-size", "25")), 18, 88, 25);
|
||
const maxSize = Math.max(minSize, clampNumber(parseFloat(cssValue("--tb-max-size", "56")), 18, 96, 56));
|
||
const rawLens = Math.round(clampNumber(parseFloat(cssValue("--tb-lens-count", "7")), 1, 15, 7));
|
||
const lensCount = rawLens % 2 === 0 ? rawLens + 1 : rawLens;
|
||
return {
|
||
minSize,
|
||
maxSize,
|
||
lensCount,
|
||
autoHide: cssValue("--tb-autohide", "0") === "1",
|
||
};
|
||
};
|
||
|
||
const getToolbarDockState = (btn) => {
|
||
let state = toolbarDockState.get(btn);
|
||
if (!state) {
|
||
state = {
|
||
scale: 1,
|
||
targetScale: 1,
|
||
scaleVelocity: 0,
|
||
shiftY: 0,
|
||
targetShiftY: 0,
|
||
shiftVelocity: 0,
|
||
};
|
||
toolbarDockState.set(btn, state);
|
||
}
|
||
return state;
|
||
};
|
||
|
||
const stepToolbarSpring = (value, velocity, target, dt) => {
|
||
let nextValue = value;
|
||
let nextVelocity = velocity;
|
||
const steps = Math.max(1, Math.ceil(dt / TOOLBAR_DOCK_MAX_STEP));
|
||
const step = dt / steps;
|
||
for (let i = 0; i < steps; i += 1) {
|
||
const displacement = nextValue - target;
|
||
const springForce = -TOOLBAR_DOCK_SPRING.stiffness * displacement;
|
||
const dampingForce = -TOOLBAR_DOCK_SPRING.damping * nextVelocity;
|
||
const acceleration = (springForce + dampingForce) / TOOLBAR_DOCK_SPRING.mass;
|
||
nextVelocity += acceleration * step;
|
||
nextValue += nextVelocity * step;
|
||
}
|
||
return [nextValue, nextVelocity];
|
||
};
|
||
|
||
const renderToolbarDockFrame = (timestamp = performance.now()) => {
|
||
const buttons = getVisibleToolbarButtons();
|
||
if (!buttons.length) {
|
||
toolbarDockAnimationFrame = null;
|
||
return;
|
||
}
|
||
const dt = toolbarDockLastTs ? Math.min(0.032, Math.max(0.001, (timestamp - toolbarDockLastTs) / 1000)) : 0.016;
|
||
toolbarDockLastTs = timestamp;
|
||
let active = false;
|
||
buttons.forEach((btn) => {
|
||
const state = getToolbarDockState(btn);
|
||
[state.scale, state.scaleVelocity] = stepToolbarSpring(state.scale, state.scaleVelocity, state.targetScale, dt);
|
||
[state.shiftY, state.shiftVelocity] = stepToolbarSpring(state.shiftY, state.shiftVelocity, state.targetShiftY, dt);
|
||
if (
|
||
Math.abs(state.scale - state.targetScale) < 0.001 &&
|
||
Math.abs(state.scaleVelocity) < 0.001 &&
|
||
Math.abs(state.shiftY - state.targetShiftY) < 0.05 &&
|
||
Math.abs(state.shiftVelocity) < 0.05
|
||
) {
|
||
state.scale = state.targetScale;
|
||
state.scaleVelocity = 0;
|
||
state.shiftY = state.targetShiftY;
|
||
state.shiftVelocity = 0;
|
||
} else {
|
||
active = true;
|
||
}
|
||
btn.style.setProperty("--tb-scale", state.scale.toFixed(4));
|
||
btn.style.setProperty("--tb-shift-y", `${state.shiftY.toFixed(2)}px`);
|
||
});
|
||
if (active) {
|
||
toolbarDockAnimationFrame = window.requestAnimationFrame(renderToolbarDockFrame);
|
||
} else {
|
||
toolbarDockAnimationFrame = null;
|
||
toolbarDockLastTs = 0;
|
||
}
|
||
};
|
||
|
||
const startToolbarDockAnimation = () => {
|
||
if (toolbarDockAnimationFrame) return;
|
||
toolbarDockLastTs = 0;
|
||
toolbarDockAnimationFrame = window.requestAnimationFrame(renderToolbarDockFrame);
|
||
};
|
||
|
||
const setToolbarButtonTarget = (btn, size, shiftY, settings) => {
|
||
const state = getToolbarDockState(btn);
|
||
state.targetScale = settings.minSize > 0 ? size / settings.minSize : 1;
|
||
state.targetShiftY = shiftY;
|
||
startToolbarDockAnimation();
|
||
};
|
||
|
||
const resetToolbarDockMagnification = () => {
|
||
const settings = readToolbarDockSettings();
|
||
bottomToolbar?.classList.remove("toolbar-dock-active");
|
||
getVisibleToolbarButtons().forEach((btn) => setToolbarButtonTarget(btn, settings.minSize, 0, settings));
|
||
if (sectionModeActive) {
|
||
positionSectionToolbar();
|
||
}
|
||
};
|
||
|
||
const updateToolbarDockMagnification = (clientY) => {
|
||
const buttons = getVisibleToolbarButtons();
|
||
if (!buttons.length) return;
|
||
const settings = readToolbarDockSettings();
|
||
const toolbarRect = bottomToolbar.getBoundingClientRect();
|
||
const entries = buttons.map((btn) => {
|
||
return {
|
||
btn,
|
||
centerY: toolbarRect.top + btn.offsetTop + btn.offsetHeight / 2,
|
||
};
|
||
}).sort((a, b) => a.centerY - b.centerY);
|
||
const gap = parseFloat(cssValue("--tb-gap", "10")) || 10;
|
||
const step = settings.minSize + gap;
|
||
const lensRadius = Math.max(
|
||
settings.minSize,
|
||
Math.floor((settings.lensCount - 1) / 2) * step + gap / 2
|
||
);
|
||
const maxScale = settings.minSize > 0 ? settings.maxSize / settings.minSize : 1;
|
||
const scaledNudge = TOOLBAR_DOCK_BASE_NUDGE *
|
||
(lensRadius / TOOLBAR_DOCK_BASE_DISTANCE) *
|
||
Math.max(0, maxScale - 1) / (TOOLBAR_DOCK_BASE_SCALE - 1) *
|
||
TOOLBAR_DOCK_NUDGE_TIGHTNESS;
|
||
entries.forEach((entry) => {
|
||
const distance = clientY - entry.centerY;
|
||
const absDistance = Math.abs(distance);
|
||
const scale = absDistance >= lensRadius
|
||
? 1
|
||
: 1 + (maxScale - 1) * (1 - absDistance / lensRadius);
|
||
const size = settings.minSize * scale;
|
||
const shiftY = absDistance > lensRadius
|
||
? Math.sign(distance || 1) * -1 * scaledNudge
|
||
: (-distance / lensRadius) * scaledNudge * scale;
|
||
setToolbarButtonTarget(entry.btn, size, shiftY, settings);
|
||
});
|
||
if (sectionModeActive) {
|
||
positionSectionToolbar();
|
||
}
|
||
};
|
||
|
||
const requestToolbarDockMagnification = (clientY) => {
|
||
updateToolbarDockMagnification(clientY);
|
||
};
|
||
|
||
const showToolbarDock = () => {
|
||
if (!bottomToolbar) return;
|
||
window.clearTimeout(toolbarDockHideTimer);
|
||
toolbarDockHideTimer = null;
|
||
bottomToolbar.classList.add("is-visible");
|
||
};
|
||
|
||
const scheduleToolbarDockHide = () => {
|
||
if (!bottomToolbar) return;
|
||
const settings = readToolbarDockSettings();
|
||
if (!settings.autoHide) return;
|
||
if (toolbarDockHideTimer) return;
|
||
toolbarDockHideTimer = window.setTimeout(() => {
|
||
toolbarDockHideTimer = null;
|
||
bottomToolbar.classList.remove("is-visible");
|
||
resetToolbarDockMagnification();
|
||
}, 5000);
|
||
};
|
||
|
||
const updateToolbarDockMode = () => {
|
||
if (!bottomToolbar) return;
|
||
const settings = readToolbarDockSettings();
|
||
bottomToolbar.classList.toggle("toolbar-autohide", settings.autoHide);
|
||
bottomToolbar.classList.toggle("is-visible", !settings.autoHide);
|
||
if (!settings.autoHide) {
|
||
window.clearTimeout(toolbarDockHideTimer);
|
||
toolbarDockHideTimer = null;
|
||
}
|
||
resetToolbarDockMagnification();
|
||
};
|
||
|
||
const setupToolbarDock = () => {
|
||
if (!bottomToolbar) return;
|
||
bottomToolbar.addEventListener("pointermove", (event) => {
|
||
showToolbarDock();
|
||
bottomToolbar.classList.add("toolbar-dock-active");
|
||
requestToolbarDockMagnification(event.clientY);
|
||
});
|
||
bottomToolbar.addEventListener("pointerenter", () => {
|
||
showToolbarDock();
|
||
bottomToolbar.classList.add("toolbar-dock-active");
|
||
});
|
||
bottomToolbar.addEventListener("pointerleave", () => {
|
||
resetToolbarDockMagnification();
|
||
scheduleToolbarDockHide();
|
||
});
|
||
window.addEventListener("pointermove", (event) => {
|
||
const settings = readToolbarDockSettings();
|
||
if (!settings.autoHide || isGuestMode()) return;
|
||
if (event.clientX <= Math.max(32, settings.maxSize * 0.75)) {
|
||
showToolbarDock();
|
||
} else if (!bottomToolbar.matches(":hover")) {
|
||
scheduleToolbarDockHide();
|
||
}
|
||
}, { passive: true });
|
||
window.addEventListener("resize", () => {
|
||
resetToolbarDockMagnification();
|
||
updateToolbarDockMode();
|
||
});
|
||
updateToolbarDockMode();
|
||
};
|
||
|
||
const hexToRgb01 = (hex) => {
|
||
if (!hex) return [1, 1, 1];
|
||
const clean = hex.trim();
|
||
if (clean.startsWith("rgb")) {
|
||
const parts = clean.replace(/rgba?\(/, "").replace(")", "").split(",").map((p) => parseFloat(p.trim()) || 0);
|
||
return [(parts[0] || 0) / 255, (parts[1] || 0) / 255, (parts[2] || 0) / 255];
|
||
}
|
||
const h = clean.replace("#", "");
|
||
const norm = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
||
const num = parseInt(norm, 16);
|
||
if (Number.isNaN(num)) return [1, 1, 1];
|
||
return [(num >> 16 & 255) / 255, (num >> 8 & 255) / 255, (num & 255) / 255];
|
||
};
|
||
|
||
const measureThemeFromCSS = () => {
|
||
const pick = (name, fallback) => {
|
||
const v = cssValue(name);
|
||
return v || fallback;
|
||
};
|
||
return {
|
||
line: pick("--measure-line", "#00bbff"),
|
||
label: pick("--measure-label", "#1f2a3c"),
|
||
labelText: pick("--measure-label-text", "#ffffff"),
|
||
dot: pick("--measure-dot", "#00bbff"),
|
||
};
|
||
};
|
||
|
||
const applyMeasureTheme = () => {
|
||
if (!measurePlugin) return;
|
||
const theme = measureThemeFromCSS();
|
||
measurePlugin.defaultColor = theme.line;
|
||
const applyToMeasurement = (m) => {
|
||
if (!m) return;
|
||
m.color = theme.line;
|
||
m._lengthLabel?.setFillColor?.(theme.label);
|
||
m._originDot?.setFillColor?.(theme.dot);
|
||
m._originDot?.setBorderColor?.(theme.line);
|
||
m._targetDot?.setFillColor?.(theme.dot);
|
||
m._targetDot?.setBorderColor?.(theme.line);
|
||
};
|
||
Object.values(measurePlugin.measurements || {}).forEach(applyToMeasurement);
|
||
document.querySelectorAll(".viewer-ruler-label").forEach((el) => {
|
||
el.style.color = theme.labelText;
|
||
el.style.background = theme.label;
|
||
el.style.borderColor = theme.line;
|
||
});
|
||
document.querySelectorAll(".viewer-ruler-dot").forEach((el) => {
|
||
el.style.background = theme.dot;
|
||
el.style.borderColor = theme.line;
|
||
});
|
||
document.querySelectorAll(".viewer-ruler-dot-clickable").forEach((el) => {
|
||
el.style.borderColor = theme.line;
|
||
});
|
||
};
|
||
|
||
const sectionPlaneThemeFromCSS = () => ({
|
||
axes: {
|
||
x: hexToRgb01(cssValue("--section-axis-x", "#ff2b2b")),
|
||
y: hexToRgb01(cssValue("--section-axis-y", "#35ff4f")),
|
||
z: hexToRgb01(cssValue("--section-axis-z", "#285bff")),
|
||
},
|
||
gizmoDiameter: clampNumber(parseFloat(cssValue("--section-gizmo-diameter", "1")), 0.5, 1.8, 1),
|
||
gizmoThickness: clampNumber(parseFloat(cssValue("--section-gizmo-thickness", "1")), 0.5, 2.5, 1),
|
||
planeFillColor: hexToRgb01(cssValue("--section-plane-fill", "#606060")),
|
||
planeFillAlpha: clampNumber(parseFloat(cssValue("--section-plane-alpha", "0.32")), 0, 1, 0.32),
|
||
planeEdgeColor: hexToRgb01(cssValue("--section-plane-edge", "#0d0d0d")),
|
||
planeEdgeAlpha: clampNumber(parseFloat(cssValue("--section-plane-edge-alpha", "0.8")), 0, 1, 0.8),
|
||
});
|
||
|
||
const applySectionPlaneTheme = () => {
|
||
if (!sectionPlanesPlugin) return;
|
||
const theme = sectionPlaneThemeFromCSS();
|
||
Object.values(sectionPlanesPlugin._controls || {}).forEach((control) => {
|
||
control?.setStyle?.(theme);
|
||
});
|
||
Object.values(sectionPlanesPlugin._overview?._planes || {}).forEach((plane) => {
|
||
const mesh = plane?._mesh;
|
||
if (!mesh) return;
|
||
if (mesh.material) {
|
||
mesh.material.diffuse = theme.planeFillColor;
|
||
mesh.material.emissive = theme.planeFillColor;
|
||
mesh.material.alpha = theme.planeFillAlpha;
|
||
mesh.material.alphaMode = theme.planeFillAlpha < 1 ? "blend" : "opaque";
|
||
}
|
||
if (mesh.edgeMaterial) {
|
||
mesh.edgeMaterial.edgeColor = theme.planeEdgeColor;
|
||
mesh.edgeMaterial.edgeAlpha = theme.planeEdgeAlpha;
|
||
}
|
||
if (mesh.highlightMaterial) {
|
||
mesh.highlightMaterial.fillColor = theme.planeFillColor;
|
||
mesh.highlightMaterial.fillAlpha = theme.planeFillAlpha;
|
||
mesh.highlightMaterial.edgeColor = theme.planeEdgeColor;
|
||
mesh.highlightMaterial.edgeAlpha = theme.planeEdgeAlpha;
|
||
}
|
||
if (mesh.selectedMaterial) {
|
||
mesh.selectedMaterial.fillColor = theme.planeFillColor;
|
||
mesh.selectedMaterial.fillAlpha = theme.planeFillAlpha;
|
||
mesh.selectedMaterial.edgeColor = theme.planeEdgeColor;
|
||
mesh.selectedMaterial.edgeAlpha = theme.planeEdgeAlpha;
|
||
}
|
||
mesh.opacity = theme.planeFillAlpha;
|
||
});
|
||
viewer?.scene?.glRedraw?.();
|
||
sectionPlanesPlugin._overview?._scene?.glRedraw?.();
|
||
};
|
||
|
||
const applyHighlightTheme = () => {
|
||
if (!viewer?.scene?.highlightMaterial) return;
|
||
const hexToRGB = (hex) => {
|
||
const h = hex.replace("#", "");
|
||
const num = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);
|
||
if (Number.isNaN(num)) return [0.72, 0.2, 1];
|
||
return [(num >> 16 & 255) / 255, (num >> 8 & 255) / 255, (num & 255) / 255];
|
||
};
|
||
const hl = cssValue("--highlight-color", "#b833ff");
|
||
const color = hexToRGB(hl);
|
||
viewer.scene.highlightMaterial.color = color;
|
||
if (viewer.scene.highlightMaterial.edgeColor) {
|
||
viewer.scene.highlightMaterial.edgeColor = color;
|
||
}
|
||
if (viewer.scene.highlightMaterial.fillColor) {
|
||
viewer.scene.highlightMaterial.fillColor = [...color, 0.5];
|
||
}
|
||
viewer.scene.glRedraw();
|
||
};
|
||
|
||
const normalizeType = (type, src = "") => {
|
||
const lower = (type || "").toLowerCase();
|
||
if (lower === "glb" || lower === "gltf") return "gltf";
|
||
if (lower === "laz" || lower === "las" || lower === "pointcloud" || lower === "point_cloud") return "las";
|
||
if (lower === "xkt") return "xkt";
|
||
if (lower === "bim") return "bim";
|
||
if (lower === "obj") return "obj";
|
||
if (lower === "stl") return "stl";
|
||
if (src) {
|
||
return normalizeType(guessTypeFromName(src));
|
||
}
|
||
return lower || null;
|
||
};
|
||
|
||
const log = (text, isError = false) => {
|
||
if (!logList) return;
|
||
const entry = document.createElement("div");
|
||
entry.textContent = text;
|
||
entry.style.color = isError ? "var(--error)" : "var(--muted)";
|
||
logList.appendChild(entry);
|
||
while (logList.children.length > 20) {
|
||
logList.removeChild(logList.firstChild);
|
||
}
|
||
logList.scrollTop = logList.scrollHeight;
|
||
if (isError) console.error(text);
|
||
};
|
||
|
||
const reportViewerEvent = (event, data = {}, level = "info") => {
|
||
const payload = {
|
||
event,
|
||
level,
|
||
data,
|
||
href: window.location.href,
|
||
ts: new Date().toISOString(),
|
||
};
|
||
const logger = level === "error" ? console.error : console.log;
|
||
logger(`[beam-viewer] ${event}`, data);
|
||
fetch("/api/client-log", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
keepalive: true,
|
||
}).catch(() => {});
|
||
};
|
||
|
||
window.addEventListener("error", (event) => {
|
||
reportViewerEvent("window-error", {
|
||
message: event.message,
|
||
filename: event.filename,
|
||
lineno: event.lineno,
|
||
colno: event.colno,
|
||
}, "error");
|
||
});
|
||
|
||
window.addEventListener("unhandledrejection", (event) => {
|
||
reportViewerEvent("window-unhandled-rejection", {
|
||
reason: event.reason?.message || String(event.reason),
|
||
stack: event.reason?.stack,
|
||
}, "error");
|
||
});
|
||
|
||
const setStatus = (text) => {
|
||
statusText.textContent = text;
|
||
statusText.style.color = "";
|
||
log(text, false);
|
||
};
|
||
|
||
|
||
const showError = (text) => {
|
||
statusText.textContent = text;
|
||
statusText.style.color = "var(--error)";
|
||
setTimeout(() => {
|
||
statusText.style.color = "";
|
||
}, 2000);
|
||
log(text, true);
|
||
};
|
||
|
||
const setLoading = (flag) => {
|
||
if (!loaderEl) return;
|
||
if (flag) {
|
||
loaderEl.classList.remove("hidden");
|
||
} else {
|
||
loaderEl.classList.add("hidden");
|
||
}
|
||
};
|
||
|
||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
const probeWebGLContext = (canvas, contextAttr = {}) => {
|
||
if (!canvas) return null;
|
||
const contextNames = ["webgl2", "experimental-webgl", "webgl"];
|
||
for (const contextName of contextNames) {
|
||
try {
|
||
const gl = canvas.getContext(contextName, contextAttr);
|
||
if (gl) return { contextName, gl };
|
||
} catch (_error) {
|
||
// Try next context type.
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const waitForViewerCanvasReady = async (contextAttr = {}) => {
|
||
for (let attempt = 1; attempt <= 30; attempt++) {
|
||
const rect = mainCanvas?.getBoundingClientRect?.();
|
||
const hasSize = !!rect && rect.width >= 32 && rect.height >= 32;
|
||
const webglProbe = hasSize ? probeWebGLContext(mainCanvas, contextAttr) : null;
|
||
if (hasSize && webglProbe?.gl) {
|
||
reportViewerEvent("webgl-ready", {
|
||
attempt,
|
||
contextName: webglProbe.contextName,
|
||
height: Math.round(rect.height),
|
||
width: Math.round(rect.width),
|
||
});
|
||
return;
|
||
}
|
||
if (attempt === 1 || attempt % 10 === 0) {
|
||
reportViewerEvent("webgl-wait", {
|
||
attempt,
|
||
hasCanvas: !!mainCanvas,
|
||
height: rect ? Math.round(rect.height) : 0,
|
||
hasSize,
|
||
width: rect ? Math.round(rect.width) : 0,
|
||
webgl: !!webglProbe?.gl,
|
||
});
|
||
}
|
||
await delay(150);
|
||
}
|
||
throw new Error("WebGL-контекст недоступен: браузер не выдал canvas context для BIM Viewer");
|
||
};
|
||
|
||
const updateNavCubeVisibility = () => {
|
||
if (!navCubeCanvas) return;
|
||
navCubeCanvas.style.display = activeModels.length ? "block" : "none";
|
||
};
|
||
|
||
const setMeasureActive = (active) => {
|
||
const isActive = !!active && !!measurePlugin && !!measureControl;
|
||
measureActive = isActive;
|
||
if (measureControl) {
|
||
if (isActive) {
|
||
measureControl.activate();
|
||
} else {
|
||
measureControl.deactivate();
|
||
}
|
||
}
|
||
if (measureButton) {
|
||
measureButton.classList.toggle("active", isActive);
|
||
}
|
||
if (!isActive) {
|
||
closeMeasurementModal();
|
||
}
|
||
};
|
||
|
||
const closeMeasurementModal = () => {
|
||
measurementModal?.hide?.();
|
||
activeMeasurementId = null;
|
||
};
|
||
|
||
const clearMeasurements = () => {
|
||
measurePlugin?.clear?.();
|
||
closeMeasurementModal();
|
||
};
|
||
|
||
const getSectionPlaneIds = () => Object.keys(sectionPlanesPlugin?.sectionPlanes || {});
|
||
|
||
const getActiveSectionPlane = () => {
|
||
if (!sectionPlanesPlugin) return null;
|
||
if (activeSectionPlaneId && sectionPlanesPlugin.sectionPlanes?.[activeSectionPlaneId]) {
|
||
return sectionPlanesPlugin.sectionPlanes[activeSectionPlaneId];
|
||
}
|
||
const firstId = getSectionPlaneIds()[0] || null;
|
||
activeSectionPlaneId = firstId;
|
||
return firstId ? sectionPlanesPlugin.sectionPlanes[firstId] : null;
|
||
};
|
||
|
||
const setAllSectionPlanesActive = (active) => {
|
||
getSectionPlaneIds().forEach((id) => {
|
||
const sectionPlane = sectionPlanesPlugin?.sectionPlanes?.[id];
|
||
if (sectionPlane) {
|
||
sectionPlane.active = !!active;
|
||
}
|
||
});
|
||
};
|
||
|
||
const getSectionCenter = () => {
|
||
const sceneAABB = viewer?.scene?.aabb;
|
||
const aabb = Array.isArray(sceneAABB) || ArrayBuffer.isView(sceneAABB)
|
||
? Array.from(sceneAABB)
|
||
: null;
|
||
const center = centerFromAABB(aabb);
|
||
return center.every((value) => Number.isFinite(value)) ? center : [0, 0, 0];
|
||
};
|
||
|
||
const getCameraSectionDir = () => {
|
||
const eye = snapshotVec(viewer?.camera?.eye) || [0, 0, 1];
|
||
const look = snapshotVec(viewer?.camera?.look) || [0, 0, 0];
|
||
return normalizeVec3([
|
||
look[0] - eye[0],
|
||
look[1] - eye[1],
|
||
look[2] - eye[2],
|
||
], [0, 0, -1]);
|
||
};
|
||
|
||
const setSectionAxisActive = (axis) => {
|
||
activeSectionAxis = axis || null;
|
||
sectionToolbar?.querySelectorAll("[data-section-action]").forEach((btn) => {
|
||
btn.classList.toggle("active", !!axis && btn.dataset.sectionAction === axis);
|
||
});
|
||
};
|
||
|
||
const positionSectionToolbar = () => {
|
||
if (!sectionButton || !sectionToolbar) return;
|
||
const rect = sectionButton.getBoundingClientRect();
|
||
if (!rect.width || !rect.height) return;
|
||
const gap = parseFloat(cssValue("--tb-gap", "10")) || 10;
|
||
const root = document.documentElement;
|
||
root.style.setProperty("--section-toolbar-left", `${Math.round(rect.right + gap)}px`);
|
||
root.style.setProperty("--section-toolbar-bottom", `${Math.round(Math.max(8, window.innerHeight - rect.bottom))}px`);
|
||
};
|
||
|
||
const setSectionModeActive = (active) => {
|
||
sectionModeActive = !!active && !!sectionPlanesPlugin && getSectionPlaneIds().length > 0;
|
||
if (sectionModeActive) {
|
||
positionSectionToolbar();
|
||
}
|
||
sectionButton?.classList.toggle("active", sectionModeActive);
|
||
sectionToolbar?.classList.toggle("hidden", !sectionModeActive);
|
||
document.body.dataset.sectionMode = sectionModeActive ? "active" : "";
|
||
sectionPlanesPlugin?.setOverviewVisible?.(sectionModeActive);
|
||
if (!sectionModeActive) {
|
||
setSectionAxisActive(null);
|
||
sectionPlanesPlugin?.hideControl?.();
|
||
}
|
||
};
|
||
|
||
const updateSectionControls = () => {
|
||
const available = !!sectionPlanesPlugin && activeModels.length > 0;
|
||
if (sectionButton) {
|
||
sectionButton.disabled = !available;
|
||
sectionButton.classList.toggle("disabled", !available);
|
||
sectionButton.classList.toggle("active", available && sectionModeActive);
|
||
}
|
||
if (!available) {
|
||
sectionToolbar?.classList.add("hidden");
|
||
document.body.dataset.sectionMode = "";
|
||
sectionPlanesPlugin?.setOverviewVisible?.(false);
|
||
}
|
||
};
|
||
|
||
const createOrUpdateSectionPlane = (axis = "camera") => {
|
||
if (!sectionPlanesPlugin) {
|
||
showError("Разрез недоступен в текущей сборке SDK");
|
||
return null;
|
||
}
|
||
if (!activeModels.length) {
|
||
showError("Сначала загрузите модель");
|
||
return null;
|
||
}
|
||
|
||
setMeasureActive(false);
|
||
const dirs = {
|
||
camera: getCameraSectionDir(),
|
||
x: [1, 0, 0],
|
||
y: [0, 1, 0],
|
||
z: [0, 0, 1],
|
||
};
|
||
const dir = dirs[axis] || dirs.camera;
|
||
const pos = getSectionCenter();
|
||
let sectionPlane = getActiveSectionPlane();
|
||
|
||
if (!sectionPlane) {
|
||
const id = `sectionPlane-${++sectionPlaneCounter}`;
|
||
sectionPlane = sectionPlanesPlugin.createSectionPlane({ id, pos, dir });
|
||
activeSectionPlaneId = sectionPlane.id || id;
|
||
} else {
|
||
sectionPlane.pos = pos;
|
||
sectionPlane.dir = dir;
|
||
sectionPlane.active = true;
|
||
activeSectionPlaneId = sectionPlane.id || activeSectionPlaneId;
|
||
}
|
||
|
||
applySectionPlaneTheme();
|
||
sectionPlanesPlugin.showControl(activeSectionPlaneId);
|
||
setSectionModeActive(true);
|
||
setSectionAxisActive(axis);
|
||
setStatus("Разрез включён");
|
||
rearmCameraControl();
|
||
return sectionPlane;
|
||
};
|
||
|
||
const clearSectionPlanes = () => {
|
||
sectionPlanesPlugin?.clear?.();
|
||
activeSectionPlaneId = null;
|
||
setSectionModeActive(false);
|
||
};
|
||
|
||
const flipSectionPlanes = () => {
|
||
if (!getActiveSectionPlane()) {
|
||
createOrUpdateSectionPlane("camera");
|
||
return;
|
||
}
|
||
setAllSectionPlanesActive(true);
|
||
sectionPlanesPlugin?.flipSectionPlanes?.();
|
||
sectionPlanesPlugin?.showControl?.(activeSectionPlaneId);
|
||
setSectionModeActive(true);
|
||
setStatus("Разрез инвертирован");
|
||
};
|
||
|
||
const toggleSectionPlanes = () => {
|
||
if (sectionModeActive) {
|
||
setAllSectionPlanesActive(false);
|
||
setSectionModeActive(false);
|
||
setStatus("Разрез выключен");
|
||
return;
|
||
}
|
||
if (getSectionPlaneIds().length > 0) {
|
||
setAllSectionPlanesActive(true);
|
||
activeSectionPlaneId = activeSectionPlaneId || getSectionPlaneIds()[0];
|
||
sectionPlanesPlugin?.showControl?.(activeSectionPlaneId);
|
||
setSectionModeActive(true);
|
||
setStatus("Разрез включён");
|
||
return;
|
||
}
|
||
createOrUpdateSectionPlane("camera");
|
||
};
|
||
|
||
const rebuildNavCube = (sdk) => {
|
||
if (!navCubeCanvas || !viewer) return;
|
||
const cubeColor = cssValue("--cube-base", "#7b3fff");
|
||
const cubeHover = cssValue("--cube-hover", "rgba(255,255,255,0.4)");
|
||
const cubeText = cssValue("--cube-text", "#ffffff");
|
||
const edgeColorHex = cssValue("--cube-edge-color", "#e0e7ff");
|
||
const edgeAlpha = parseFloat(cssValue("--cube-edge-alpha", "0.6")) || 0.6;
|
||
if (!navCubePlugin) {
|
||
navCubePlugin = new sdk.NavCubePlugin(viewer, {
|
||
canvasId: "navCube",
|
||
size: 100,
|
||
visible: true,
|
||
color: cubeColor,
|
||
textColor: cubeText,
|
||
hoverColor: cubeHover,
|
||
});
|
||
}
|
||
// update textures/colors on existing plugin without recreating WebGL context
|
||
const navScene = navCubePlugin?._navCubeScene;
|
||
const cubeTextureCtor = navCubePlugin?._cubeTextureCanvas?.constructor;
|
||
if (cubeTextureCtor && navScene) {
|
||
navCubePlugin._cubeTextureCanvas?.destroy?.();
|
||
const ctc = new cubeTextureCtor(viewer, navScene, {
|
||
color: cubeColor,
|
||
hoverColor: cubeHover,
|
||
textColor: cubeText,
|
||
});
|
||
ctc.clear?.();
|
||
navCubePlugin._cubeTextureCanvas = ctc;
|
||
const image = ctc.getImage();
|
||
if (navCubePlugin._cubeSampler) {
|
||
navCubePlugin._cubeSampler.image = image;
|
||
navCubePlugin._cubeSampler._imageDirty = true;
|
||
navCubePlugin._cubeSampler.glTexture = null;
|
||
}
|
||
if (navCubePlugin._cubeMesh?.material) {
|
||
navCubePlugin._cubeMesh.material.diffuseMap.image = image;
|
||
navCubePlugin._cubeMesh.material.diffuseMap._imageDirty = true;
|
||
navCubePlugin._cubeMesh.material.diffuseMap.glTexture = null;
|
||
navCubePlugin._cubeMesh.material.emissiveMap.image = image;
|
||
navCubePlugin._cubeMesh.material.emissiveMap._imageDirty = true;
|
||
navCubePlugin._cubeMesh.material.emissiveMap.glTexture = null;
|
||
}
|
||
navCubePlugin._repaint?.();
|
||
}
|
||
if (navScene?.edgeMaterial) {
|
||
navScene.edgeMaterial.edgeColor = hexToRgb01(edgeColorHex);
|
||
navScene.edgeMaterial.edgeAlpha = edgeAlpha;
|
||
navScene.glRedraw();
|
||
}
|
||
updateNavCubeVisibility();
|
||
};
|
||
|
||
const updatePanelsVisibility = () => {
|
||
const hasModels = activeModels.length > 0;
|
||
if (sidePanelEl) {
|
||
sidePanelEl.classList.toggle("is-collapsed", hasModels && inspectorCollapsed);
|
||
sidePanelEl.style.display = hasModels && !inspectorCollapsed ? "flex" : "none";
|
||
}
|
||
if (inspectorRestoreTray) {
|
||
inspectorRestoreTray.hidden = !hasModels || !inspectorCollapsed;
|
||
}
|
||
if (treeContainer) treeContainer.classList.toggle("hidden", !hasModels);
|
||
const showActions = hasModels || !!currentProjectMeta || !!lastAttemptedProjectId;
|
||
if (saveProjectSection) {
|
||
saveProjectSection.classList.toggle("hidden", isGuestMode() || !showActions);
|
||
}
|
||
if (projectNameSection) {
|
||
projectNameSection.classList.toggle("hidden", isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId));
|
||
}
|
||
if (saveProjectButton) {
|
||
saveProjectButton.disabled = isGuestMode() || !hasModels;
|
||
}
|
||
if (saveModelSettingsButton) {
|
||
saveModelSettingsButton.disabled = isGuestMode() || !hasModels || !hasModelSettingsTarget();
|
||
}
|
||
if (deleteProjectButton) {
|
||
deleteProjectButton.disabled = isGuestMode() || !(currentProjectMeta || lastAttemptedProjectId);
|
||
}
|
||
if (projectNameInput) {
|
||
const hasProject = !isGuestMode() && !!(currentProjectMeta || lastAttemptedProjectId);
|
||
projectNameInput.disabled = !hasProject;
|
||
if (!hasProject) {
|
||
projectNameInput.value = "";
|
||
}
|
||
}
|
||
if (!hasModels) {
|
||
commentsModeActive = false;
|
||
commentsCollapsed = false;
|
||
commentContextTarget = null;
|
||
activeCommentId = null;
|
||
hoverCommentId = null;
|
||
commentRecords = [];
|
||
commentSourcesLoaded.clear();
|
||
closeCommentModal();
|
||
}
|
||
updateSectionControls();
|
||
updateCommentsVisibility();
|
||
updateModelVersionControl();
|
||
applyRuntimeMode();
|
||
};
|
||
|
||
const setInspectorCollapsed = (collapsed) => {
|
||
inspectorCollapsed = !!collapsed;
|
||
if (inspectorCollapseButton) {
|
||
inspectorCollapseButton.setAttribute("aria-expanded", String(!inspectorCollapsed));
|
||
}
|
||
updatePanelsVisibility();
|
||
focusCanvasAndControls();
|
||
};
|
||
|
||
const centerFromAABB = (aabb) => {
|
||
if (!Array.isArray(aabb) || aabb.length !== 6) return [0, 0, 0];
|
||
return [
|
||
(aabb[0] + aabb[3]) / 2,
|
||
(aabb[1] + aabb[4]) / 2,
|
||
(aabb[2] + aabb[5]) / 2,
|
||
];
|
||
};
|
||
|
||
const updateGlobalInputs = (pos) => {
|
||
if (!globalInputs.x || !globalInputs.y || !globalInputs.z) return;
|
||
const safe = pos && pos.length === 3 ? pos : ["", "", ""];
|
||
globalInputs.x.value = safe[0];
|
||
globalInputs.y.value = safe[1];
|
||
globalInputs.z.value = safe[2];
|
||
};
|
||
|
||
const getAABBForSelection = (ids, modelId = null) => {
|
||
if (!viewer || !viewer.scene) return null;
|
||
const scene = viewer.scene;
|
||
if (ids && ids.length) {
|
||
return Array.from(scene.getAABB(ids) || []);
|
||
}
|
||
if (modelId && scene.models?.[modelId]) {
|
||
const model = scene.models[modelId];
|
||
const idsFromModel = Object.keys(model.objects || {});
|
||
return Array.from(scene.getAABB(idsFromModel) || model.aabb || []);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const applyGlobalTarget = () => {
|
||
if (!viewer || !viewer.scene) return;
|
||
const scene = viewer.scene;
|
||
const target = [
|
||
parseFloat(globalInputs.x?.value),
|
||
parseFloat(globalInputs.y?.value),
|
||
parseFloat(globalInputs.z?.value),
|
||
];
|
||
try {
|
||
if (!target.every((v) => Number.isFinite(v))) {
|
||
console.warn("[GlobalCoords] invalid target", target);
|
||
return;
|
||
}
|
||
const ids = lastSelection.ids || [];
|
||
const modelId = lastSelection.modelId;
|
||
const currentAABB = getAABBForSelection(ids, modelId);
|
||
if (!currentAABB || currentAABB.length !== 6) {
|
||
console.warn("[GlobalCoords] no AABB for selection");
|
||
return;
|
||
}
|
||
const currentCenter = centerFromAABB(currentAABB);
|
||
const delta = [
|
||
target[0] - currentCenter[0],
|
||
target[1] - currentCenter[1],
|
||
target[2] - currentCenter[2],
|
||
];
|
||
// если есть модель — сдвигаем модель
|
||
if (modelId && scene.models?.[modelId]) {
|
||
const model = scene.models[modelId];
|
||
const basePos = model.position || [0, 0, 0];
|
||
model.position = [
|
||
(basePos[0] || 0) + delta[0],
|
||
(basePos[1] || 0) + delta[1],
|
||
(basePos[2] || 0) + delta[2],
|
||
];
|
||
// persist transform
|
||
persistTransform(modelId, "__model__", {
|
||
position: [...model.position],
|
||
rotation: model.rotation ? [...model.rotation] : [0, 0, 0],
|
||
scale: model.scale ? [...model.scale] : [1, 1, 1],
|
||
});
|
||
} else if (ids.length) {
|
||
// иначе сдвигаем объекты по отдельности
|
||
ids.forEach((id) => {
|
||
const ent = scene.objects?.[id];
|
||
if (!ent) return;
|
||
const basePos = ent.position || [0, 0, 0];
|
||
ent.position = [
|
||
(basePos[0] || 0) + delta[0],
|
||
(basePos[1] || 0) + delta[1],
|
||
(basePos[2] || 0) + delta[2],
|
||
];
|
||
if (modelId) {
|
||
persistTransform(modelId, id, {
|
||
position: [...ent.position],
|
||
rotation: ent.rotation ? [...ent.rotation] : [0, 0, 0],
|
||
scale: ent.scale ? [...ent.scale] : [1, 1, 1],
|
||
});
|
||
}
|
||
});
|
||
} else {
|
||
console.warn("[GlobalCoords] nothing selected");
|
||
return;
|
||
}
|
||
// обновляем отображение и состояние
|
||
const newAABB = getAABBForSelection(ids, modelId);
|
||
updateGlobalInputs(centerFromAABB(newAABB || []));
|
||
scene.setAABBDirty?.();
|
||
scene.glRedraw?.();
|
||
console.log("[GlobalCoords] applied delta", { delta, target, modelId, count: ids.length });
|
||
} finally {
|
||
ensureCameraControlActive();
|
||
resetCameraControl();
|
||
if (viewer?.cameraControl) {
|
||
viewer.cameraControl.enabled = true;
|
||
viewer.cameraControl.active = true;
|
||
}
|
||
focusCanvasAndControls();
|
||
}
|
||
};
|
||
|
||
["x", "y", "z"].forEach((key) => {
|
||
const el = globalInputs[key];
|
||
if (!el) return;
|
||
el.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
applyGlobalTarget();
|
||
}
|
||
});
|
||
el.addEventListener("blur", () => {
|
||
applyGlobalTarget();
|
||
focusCanvasAndControls();
|
||
});
|
||
});
|
||
|
||
const scheduleCameraRearm = () => {
|
||
rearmCameraControl();
|
||
window.setTimeout(rearmCameraControl, 60);
|
||
window.setTimeout(rearmCameraControl, 220);
|
||
};
|
||
|
||
window.addEventListener("blur", scheduleCameraRearm);
|
||
window.addEventListener("focus", scheduleCameraRearm);
|
||
window.addEventListener("pageshow", scheduleCameraRearm);
|
||
window.addEventListener("pointerup", scheduleCameraRearm, true);
|
||
window.addEventListener("mouseup", scheduleCameraRearm, true);
|
||
window.addEventListener("touchend", scheduleCameraRearm, true);
|
||
window.addEventListener("touchcancel", scheduleCameraRearm, true);
|
||
window.addEventListener("resize", () => {
|
||
if (sectionModeActive) {
|
||
positionSectionToolbar();
|
||
}
|
||
});
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (!document.hidden) {
|
||
scheduleCameraRearm();
|
||
}
|
||
});
|
||
|
||
const makeTranslationMat = (x, y, z) => {
|
||
const m = mathUtil?.identityMat4 ? mathUtil.identityMat4() : [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
|
||
if (m.length === 16) {
|
||
m[12] = x;
|
||
m[13] = y;
|
||
m[14] = z;
|
||
}
|
||
return m;
|
||
};
|
||
|
||
const degToRad = (deg) => (deg * Math.PI) / 180;
|
||
|
||
const rotateVec3 = (v, rotDeg) => {
|
||
const [x, y, z] = v;
|
||
const rx = degToRad(rotDeg[0] || 0);
|
||
const ry = degToRad(rotDeg[1] || 0);
|
||
const rz = degToRad(rotDeg[2] || 0);
|
||
// X
|
||
let cy = Math.cos(rx), sy = Math.sin(rx);
|
||
let v1y = y * cy - z * sy;
|
||
let v1z = y * sy + z * cy;
|
||
let v1x = x;
|
||
// Y
|
||
let cz = Math.cos(ry), sz = Math.sin(ry);
|
||
let v2x = v1x * cz + v1z * sz;
|
||
let v2z = -v1x * sz + v1z * cz;
|
||
let v2y = v1y;
|
||
// Z
|
||
let cx = Math.cos(rz), sx = Math.sin(rz);
|
||
let v3x = v2x * cx - v2y * sx;
|
||
let v3y = v2x * sx + v2y * cx;
|
||
let v3z = v2z;
|
||
return [v3x, v3y, v3z];
|
||
};
|
||
|
||
const applyGroupTransform = (transform) => {
|
||
if (!selectionState || !viewer) return;
|
||
const scene = viewer.scene;
|
||
const current = selectionState.transform || {
|
||
position: [0, 0, 0],
|
||
rotation: [0, 0, 0],
|
||
scale: [1, 1, 1],
|
||
};
|
||
const pos = transform?.position || current.position || [0, 0, 0];
|
||
const rot = transform?.rotation || current.rotation || [0, 0, 0];
|
||
const scl = transform?.scale || current.scale || [1, 1, 1];
|
||
const deltaPos = [
|
||
pos[0] - (current.position?.[0] || 0),
|
||
pos[1] - (current.position?.[1] || 0),
|
||
pos[2] - (current.position?.[2] || 0),
|
||
];
|
||
const deltaRot = [
|
||
rot[0] - (current.rotation?.[0] || 0),
|
||
rot[1] - (current.rotation?.[1] || 0),
|
||
rot[2] - (current.rotation?.[2] || 0),
|
||
];
|
||
const safeScale = [
|
||
current.scale?.[0] || 1,
|
||
current.scale?.[1] || 1,
|
||
current.scale?.[2] || 1,
|
||
];
|
||
const deltaScale = [
|
||
safeScale[0] ? (scl[0] || 1) / safeScale[0] : scl[0] || 1,
|
||
safeScale[1] ? (scl[1] || 1) / safeScale[1] : scl[1] || 1,
|
||
safeScale[2] ? (scl[2] || 1) / safeScale[2] : scl[2] || 1,
|
||
];
|
||
|
||
selectionState.transform = {
|
||
position: [...pos],
|
||
rotation: [...rot],
|
||
scale: [...scl],
|
||
};
|
||
|
||
let idsToApply = selectionState.ids && selectionState.ids.length ? [...selectionState.ids] : [];
|
||
if (!idsToApply.length && selectionState.modelId) {
|
||
idsToApply = ["__model__"];
|
||
}
|
||
const isGroup = idsToApply.length > 1;
|
||
const pivot = selectionState.pivotCenter || [0, 0, 0];
|
||
let applied = false;
|
||
idsToApply.forEach((objectId) => {
|
||
const entity = objectId === "__model__"
|
||
? viewer.scene?.models?.[selectionState.modelId]
|
||
: resolveEntity(objectId, selectionState.modelId);
|
||
if (!entity) return;
|
||
const basePos = entity.position || [0, 0, 0];
|
||
const baseRot = entity.rotation || [0, 0, 0];
|
||
const baseScale = entity.scale || [1, 1, 1];
|
||
|
||
let newPos = [
|
||
basePos[0] + deltaPos[0],
|
||
basePos[1] + deltaPos[1],
|
||
basePos[2] + deltaPos[2],
|
||
];
|
||
if (isGroup) {
|
||
const local = [
|
||
basePos[0] - pivot[0],
|
||
basePos[1] - pivot[1],
|
||
basePos[2] - pivot[2],
|
||
];
|
||
// масштаб вокруг пивота
|
||
const scaled = [
|
||
local[0] * deltaScale[0],
|
||
local[1] * deltaScale[1],
|
||
local[2] * deltaScale[2],
|
||
];
|
||
const rotated = rotateVec3(scaled, deltaRot);
|
||
newPos = [
|
||
pivot[0] + rotated[0] + deltaPos[0],
|
||
pivot[1] + rotated[1] + deltaPos[1],
|
||
pivot[2] + rotated[2] + deltaPos[2],
|
||
];
|
||
}
|
||
|
||
entity.position = newPos;
|
||
entity.rotation = [
|
||
baseRot[0] + deltaRot[0],
|
||
baseRot[1] + deltaRot[1],
|
||
baseRot[2] + deltaRot[2],
|
||
];
|
||
entity.scale = [
|
||
baseScale[0] * deltaScale[0],
|
||
baseScale[1] * deltaScale[1],
|
||
baseScale[2] * deltaScale[2],
|
||
];
|
||
if (selectionState.modelId) {
|
||
const key = objectId === "__model__" ? "__model__" : (objectId || "__model__");
|
||
persistTransform(selectionState.modelId, key, {
|
||
position: [...entity.position],
|
||
rotation: [...entity.rotation],
|
||
scale: [...entity.scale],
|
||
});
|
||
}
|
||
applied = true;
|
||
});
|
||
if (!applied && selectionState.modelId && viewer.scene?.models?.[selectionState.modelId]) {
|
||
const model = viewer.scene.models[selectionState.modelId];
|
||
const basePos = model.position || [0, 0, 0];
|
||
const baseRot = model.rotation || [0, 0, 0];
|
||
const baseScale = model.scale || [1, 1, 1];
|
||
model.position = [
|
||
basePos[0] + deltaPos[0],
|
||
basePos[1] + deltaPos[1],
|
||
basePos[2] + deltaPos[2],
|
||
];
|
||
model.rotation = [
|
||
baseRot[0] + deltaRot[0],
|
||
baseRot[1] + deltaRot[1],
|
||
baseRot[2] + deltaRot[2],
|
||
];
|
||
model.scale = [
|
||
baseScale[0] * deltaScale[0],
|
||
baseScale[1] * deltaScale[1],
|
||
baseScale[2] * deltaScale[2],
|
||
];
|
||
persistTransform(selectionState.modelId, "__model__", {
|
||
position: [...model.position],
|
||
rotation: [...model.rotation],
|
||
scale: [...model.scale],
|
||
});
|
||
}
|
||
|
||
// при общем сдвиге переносим пивот
|
||
selectionState.pivotCenter = [
|
||
(pivot[0] || 0) + deltaPos[0],
|
||
(pivot[1] || 0) + deltaPos[1],
|
||
(pivot[2] || 0) + deltaPos[2],
|
||
];
|
||
|
||
updateTransformInputs(selectionState.transform);
|
||
scene.setAABBDirty?.();
|
||
scene.glRedraw?.();
|
||
};
|
||
|
||
const setSelection = (ids, modelId) => {
|
||
if (!viewer) return;
|
||
const scene = viewer.scene;
|
||
let uniqueIds = Array.from(new Set(ids || [])).filter(Boolean);
|
||
if ((!uniqueIds.length) && modelId && scene.models?.[modelId]) {
|
||
uniqueIds = Object.keys(scene.models[modelId].objects || {});
|
||
}
|
||
if (!uniqueIds.length && !modelId && activeModels.length) {
|
||
modelId = activeModels[0]?.model?.id || activeModels[0]?.id || null;
|
||
if (modelId && scene.models?.[modelId]) {
|
||
uniqueIds = Object.keys(scene.models[modelId].objects || {});
|
||
}
|
||
}
|
||
if (!uniqueIds.length) {
|
||
selectionState = null;
|
||
updateTransformInputs(null);
|
||
updateGlobalInputs(null);
|
||
gizmo?.clearTarget?.();
|
||
activeTransformTarget = null;
|
||
return;
|
||
}
|
||
lastSelection.ids = uniqueIds.slice(0);
|
||
lastSelection.modelId = modelId || null;
|
||
// если выбрали ровно один объект — работаем через гизму как раньше
|
||
if (uniqueIds.length === 1) {
|
||
const aabbSingle = getAABBForSelection(uniqueIds, modelId);
|
||
updateGlobalInputs(centerFromAABB(aabbSingle || []));
|
||
selectionState = null;
|
||
updateTransformInputs(null);
|
||
gizmo?.setTarget?.({ modelId, objectId: uniqueIds[0] });
|
||
activeTransformTarget = { modelId, objectId: uniqueIds[0] || "__model__" };
|
||
return;
|
||
}
|
||
const aabb = Array.from(scene.getAABB(uniqueIds) || []);
|
||
const pivotCenter = centerFromAABB(aabb);
|
||
const baseTransforms = new Map();
|
||
uniqueIds.forEach((id) => {
|
||
const ent = resolveEntity(id, modelId);
|
||
if (ent) {
|
||
baseTransforms.set(id, {
|
||
position: ent.position ? [...ent.position] : [0, 0, 0],
|
||
rotation: ent.rotation ? [...ent.rotation] : [0, 0, 0],
|
||
scale: ent.scale ? [...ent.scale] : [1, 1, 1],
|
||
});
|
||
}
|
||
});
|
||
if (baseTransforms.size === 0 && modelId && scene.models?.[modelId]) {
|
||
const model = scene.models[modelId];
|
||
baseTransforms.set("__model__", {
|
||
position: model.position ? [...model.position] : [0, 0, 0],
|
||
rotation: model.rotation ? [...model.rotation] : [0, 0, 0],
|
||
scale: model.scale ? [...model.scale] : [1, 1, 1],
|
||
});
|
||
}
|
||
const firstBase = baseTransforms.values().next().value || {
|
||
position: [0, 0, 0],
|
||
rotation: [0, 0, 0],
|
||
scale: [1, 1, 1],
|
||
};
|
||
const transform = {
|
||
position: [...(firstBase.position || [0, 0, 0])],
|
||
rotation: [...(firstBase.rotation || [0, 0, 0])],
|
||
scale: [...(firstBase.scale || [1, 1, 1])],
|
||
};
|
||
selectionState = {
|
||
ids: uniqueIds,
|
||
modelId,
|
||
pivotCenter,
|
||
baseTransforms,
|
||
transform,
|
||
};
|
||
activeTransformTarget = { modelId, objectId: uniqueIds[0] || "__model__" };
|
||
scene.setObjectsHighlighted(scene.highlightedObjectIds || [], false);
|
||
if (!isPointCloudModel(modelId)) {
|
||
scene.setObjectsHighlighted(uniqueIds, true);
|
||
}
|
||
updateTransformInputs(transform);
|
||
// Global center for group
|
||
updateGlobalInputs(centerFromAABB(aabb));
|
||
};
|
||
const collectObjectIds = (treeViewNode) => {
|
||
const ids = [];
|
||
if (!treeViewNode) return ids;
|
||
console.info("[DC Tree] collectObjectIds start", {
|
||
nodeId: treeViewNode.nodeId || treeViewNode.id,
|
||
title: treeViewNode.title || treeViewNode.text || treeViewNode.name
|
||
});
|
||
const plugin = treeViewNode.treeViewPlugin || treeView;
|
||
if (plugin?.withNodeTree) {
|
||
plugin.withNodeTree(treeViewNode, (node) => {
|
||
if (node.objectId) {
|
||
ids.push(node.objectId);
|
||
}
|
||
if (node.metaObjectId && viewer?.metaScene?.metaObjects?.[node.metaObjectId]) {
|
||
ids.push(...viewer.metaScene.metaObjects[node.metaObjectId].getObjectIDsInSubtree());
|
||
}
|
||
});
|
||
}
|
||
if (!ids.length && treeViewNode.metaObjectId && viewer?.metaScene?.metaObjects?.[treeViewNode.metaObjectId]) {
|
||
ids.push(...viewer.metaScene.metaObjects[treeViewNode.metaObjectId].getObjectIDsInSubtree());
|
||
}
|
||
if (!ids.length && treeViewNode.objectId) {
|
||
ids.push(treeViewNode.objectId);
|
||
}
|
||
// fallback: try meta by id
|
||
if (!ids.length && treeViewNode.id && viewer?.metaScene?.metaObjects?.[treeViewNode.id]) {
|
||
ids.push(...viewer.metaScene.metaObjects[treeViewNode.id].getObjectIDsInSubtree());
|
||
}
|
||
// last resort: all objects of model
|
||
if (!ids.length) {
|
||
const modelId = getModelIdFromNode(treeViewNode);
|
||
const model = modelId ? viewer?.scene?.models?.[modelId] : null;
|
||
if (model) {
|
||
ids.push(...Object.keys(model.objects || {}));
|
||
}
|
||
}
|
||
console.info("[DC Tree] collectObjectIds result", { count: ids.length, sample: ids.slice(0, 10) });
|
||
return Array.from(new Set(ids));
|
||
};
|
||
|
||
const resolveEntity = (objectId, modelId = null) => {
|
||
if (!viewer?.scene?.objects) return null;
|
||
const objects = viewer.scene.objects;
|
||
if (objects[objectId]) return objects[objectId];
|
||
const local = objectId.includes("#") ? objectId.split("#")[1] : objectId;
|
||
if (modelId) {
|
||
const globalId = `${modelId}#${local}`;
|
||
if (objects[globalId]) return objects[globalId];
|
||
}
|
||
if (objects[local]) return objects[local];
|
||
return null;
|
||
};
|
||
|
||
const parseInputNumber = (val) => {
|
||
if (val === undefined || val === null) return NaN;
|
||
const str = String(val).trim().replace(/,/g, ".");
|
||
const num = Number(str);
|
||
return Number.isFinite(num) ? num : NaN;
|
||
};
|
||
|
||
const persistTransform = (modelId, objectId, transform) => {
|
||
if (isGuestMode()) return;
|
||
if (!modelId || !transform) return;
|
||
if (!transformStore[modelId]) transformStore[modelId] = {};
|
||
const key = objectId || "__model__";
|
||
transformStore[modelId][key] = {
|
||
position: transform.position ? [...transform.position] : [0, 0, 0],
|
||
rotation: transform.rotation ? [...transform.rotation] : [0, 0, 0],
|
||
scale: transform.scale ? [...transform.scale] : [1, 1, 1],
|
||
};
|
||
// Для LAS фиксируем трансформ на уровне модели, т.к. objectId может меняться между загрузками
|
||
if (modelTypes[modelId] === "las" && key !== "__model__") {
|
||
transformStore[modelId]["__model__"] = {
|
||
position: transform.position ? [...transform.position] : [0, 0, 0],
|
||
rotation: transform.rotation ? [...transform.rotation] : [0, 0, 0],
|
||
scale: transform.scale ? [...transform.scale] : [1, 1, 1],
|
||
};
|
||
}
|
||
};
|
||
|
||
const applyStoredTransformsToModel = (modelId) => {
|
||
if (!viewer || !modelId || !sceneModels[modelId]) return false;
|
||
const modelTransforms = transformStore[modelId];
|
||
if (!modelTransforms) return false;
|
||
let applied = false;
|
||
const entries = Object.entries(modelTransforms).filter(([, tr]) => !!tr);
|
||
const hasModel = !!modelTransforms.__model__;
|
||
const nonModel = entries.filter(([key]) => key !== "__model__");
|
||
|
||
const isNeutral = (tr = {}) => {
|
||
const pos = tr.position || [0, 0, 0];
|
||
const rot = tr.rotation || [0, 0, 0];
|
||
const scale = tr.scale || [1, 1, 1];
|
||
const nearZero = (v) => Math.abs(v) < 1e-6;
|
||
return (
|
||
pos.every(nearZero) &&
|
||
rot.every(nearZero) &&
|
||
scale.every((v) => Math.abs(v - 1) < 1e-6)
|
||
);
|
||
};
|
||
|
||
// Heuristic: если у GLB/GLTF сохранён единственный трансформ (root-объект) и __model__ пустой,
|
||
// считаем его трансформом всей модели. Это устраняет кейс, когда GLB при загрузке остаётся в нулях,
|
||
// пока не кликнешь по нему.
|
||
const shouldPromote =
|
||
(modelTypes[modelId] === "gltf") &&
|
||
nonModel.length === 1 &&
|
||
(!hasModel || isNeutral(modelTransforms.__model__));
|
||
const promoteKey = shouldPromote ? nonModel[0][0] : null;
|
||
|
||
if (shouldPromote) {
|
||
const tr = nonModel[0][1];
|
||
const model = sceneModels[modelId];
|
||
if (tr && model) {
|
||
if (tr.position) model.position = [...tr.position];
|
||
if (tr.rotation) model.rotation = [...tr.rotation];
|
||
if (tr.scale) model.scale = [...tr.scale];
|
||
applied = true;
|
||
}
|
||
}
|
||
|
||
// обычное применение: __model__ + остальные объекты (кроме промотнутого root, чтобы не удвоить эффект)
|
||
entries.forEach(([objectId, tr]) => {
|
||
if (!tr) return;
|
||
if (promoteKey && objectId === promoteKey) return;
|
||
if (shouldPromote && objectId === "__model__" && isNeutral(tr)) return;
|
||
const target =
|
||
objectId === "__model__"
|
||
? sceneModels[modelId]
|
||
: resolveEntity(objectId, modelId);
|
||
const applyTarget = target || sceneModels[modelId];
|
||
if (!applyTarget) return;
|
||
if (tr.position) applyTarget.position = [...tr.position];
|
||
if (tr.rotation) applyTarget.rotation = [...tr.rotation];
|
||
if (tr.scale) applyTarget.scale = [...tr.scale];
|
||
applied = true;
|
||
});
|
||
if (applied) {
|
||
viewer.scene?.setAABBDirty?.();
|
||
viewer.scene?.glRedraw?.();
|
||
}
|
||
return applied;
|
||
};
|
||
|
||
const getModelIdFromNode = (node) => {
|
||
const title = (node?.title || node?.text || node?.name || "").trim();
|
||
const firstObject = (node && collectObjectIds(node)[0]) || null;
|
||
return node?.modelId || node?.model?.id || node?.modelIdGiven || findModelIdForObjectId(firstObject) || (title && sceneModels[title] ? title : null);
|
||
};
|
||
|
||
const findModelIdForObjectId = (objectId) => {
|
||
if (!objectId || !viewer?.scene?.models) return null;
|
||
const models = viewer.scene.models;
|
||
for (const mid of Object.keys(models)) {
|
||
if (models[mid]?.objects && Object.prototype.hasOwnProperty.call(models[mid].objects, objectId)) {
|
||
return mid;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const highlightTreeNode = (treeViewNode, { fromTree = false } = {}) => {
|
||
if (!treeViewNode || !viewer) return;
|
||
const objectIds = collectObjectIds(treeViewNode);
|
||
const modelId = getModelIdFromNode(treeViewNode) || findModelIdForObjectId(objectIds[0]);
|
||
if (treeContainer) {
|
||
const prev = treeContainer.querySelector(".highlighted-node");
|
||
if (prev) prev.classList.remove("highlighted-node");
|
||
const nodeId = treeViewNode.nodeId || treeViewNode.id;
|
||
const liEl = nodeId ? treeContainer.querySelector(`li#${CSS.escape(nodeId)}`) : null;
|
||
if (liEl) {
|
||
liEl.classList.add("highlighted-node");
|
||
}
|
||
}
|
||
if (!fromTree) {
|
||
setSelection(objectIds, modelId);
|
||
}
|
||
};
|
||
|
||
const flyToObjectIds = (ids, modelId = null) => {
|
||
if (!viewer) return;
|
||
const scene = viewer.scene;
|
||
const idsToUse = (ids && ids.length) ? ids : (selectionState?.ids || []);
|
||
console.log("[DC Tree] flyToObjectIds start", {
|
||
count: idsToUse.length,
|
||
sample: idsToUse.slice(0, 10),
|
||
modelId
|
||
});
|
||
// фильтруем только существующие в сцене
|
||
const existing = idsToUse.filter((id) => scene?.objects?.[id]);
|
||
if (existing.length !== idsToUse.length) {
|
||
console.warn("[DC Tree] filtered missing ids", {
|
||
requested: idsToUse.length,
|
||
existing: existing.length
|
||
});
|
||
}
|
||
const idsForAABB = existing.length ? existing : idsToUse;
|
||
let aabb = null;
|
||
if (idsForAABB.length && scene?.getAABB) {
|
||
aabb = scene.getAABB(idsForAABB);
|
||
} else if (modelId && scene?.models?.[modelId]) {
|
||
aabb = scene.models[modelId].aabb || scene.getAABB({ ids: Object.keys(scene.models[modelId].objects || {}) });
|
||
}
|
||
const aabbArray = Array.from(aabb || []);
|
||
console.log("[DC Tree] AABB", aabbArray);
|
||
if (!aabbArray.length || aabbArray.length !== 6 || !aabbArray.every((n) => Number.isFinite(n))) {
|
||
console.warn("[DC Tree] no valid AABB for ids", { count: idsForAABB.length });
|
||
return;
|
||
}
|
||
const center = centerFromAABB(aabbArray);
|
||
if (viewer.cameraControl) {
|
||
viewer.cameraControl.pivotPos = center;
|
||
}
|
||
viewer.cameraFlight.flyTo({ aabb: aabbArray, duration: 0.6 });
|
||
rearmCameraControl();
|
||
};
|
||
|
||
const clearCustomTrees = () => {
|
||
customTrees.forEach((el) => el.remove());
|
||
customTrees.clear();
|
||
};
|
||
|
||
const renderFallbackTree = (modelId) => {
|
||
if (!treeContainer) return;
|
||
if (customTrees.has(modelId)) return;
|
||
const model = sceneModels[modelId];
|
||
if (!model) return;
|
||
const objectIds = Object.keys(model.objects || {});
|
||
const root = document.createElement("ul");
|
||
root.className = "custom-tree";
|
||
const liRoot = document.createElement("li");
|
||
liRoot.textContent = modelId;
|
||
root.appendChild(liRoot);
|
||
if (objectIds.length) {
|
||
const ul = document.createElement("ul");
|
||
objectIds.slice(0, 2000).forEach((oid) => {
|
||
const li = document.createElement("li");
|
||
const span = document.createElement("span");
|
||
span.textContent = oid;
|
||
li.appendChild(span);
|
||
ul.appendChild(li);
|
||
});
|
||
liRoot.appendChild(ul);
|
||
}
|
||
treeContainer.appendChild(root);
|
||
customTrees.set(modelId, root);
|
||
};
|
||
|
||
const hasMetaModel = (modelId) => {
|
||
return !!viewer?.metaScene?.metaModels?.[modelId];
|
||
};
|
||
|
||
const destroyAllModels = ({ preserveMeta = false } = {}) => {
|
||
activeModels.forEach(({ model, loader }) => {
|
||
try {
|
||
model?.destroy && model.destroy();
|
||
} catch (err) {
|
||
console.warn("destroy model failed", err);
|
||
}
|
||
try {
|
||
loader?.destroy && loader.destroy();
|
||
} catch (err) {
|
||
console.warn("destroy loader failed", err);
|
||
}
|
||
});
|
||
activeModels.length = 0;
|
||
Object.keys(sceneModels).forEach((key) => delete sceneModels[key]);
|
||
Object.keys(modelTypes).forEach((key) => delete modelTypes[key]);
|
||
pointCloudColorSnapshots.clear();
|
||
if (!preserveMeta) {
|
||
Object.keys(transformStore).forEach((key) => delete transformStore[key]);
|
||
}
|
||
if (blobUrls.length) {
|
||
blobUrls.forEach((u) => {
|
||
try {
|
||
URL.revokeObjectURL(u);
|
||
} catch (_) {
|
||
// noop
|
||
}
|
||
});
|
||
blobUrls.length = 0;
|
||
}
|
||
if (currentBlobUrl) {
|
||
URL.revokeObjectURL(currentBlobUrl);
|
||
currentBlobUrl = null;
|
||
}
|
||
clearCustomTrees();
|
||
treeModelIds.clear();
|
||
try {
|
||
if (treeView?.destroy) {
|
||
treeView.destroy();
|
||
}
|
||
} catch (err) {
|
||
console.warn("treeView destroy failed", err);
|
||
}
|
||
treeView = null;
|
||
if (treeContainer?.replaceChildren) {
|
||
treeContainer.replaceChildren();
|
||
} else if (treeContainer) {
|
||
treeContainer.innerHTML = "";
|
||
}
|
||
if (sdkRef && viewer && treeContainer) {
|
||
resetTreeView(sdkRef);
|
||
}
|
||
// сбрасываем метамодели, чтобы не тянуть нейминг между проектами
|
||
if (viewer?.metaScene?.metaModels) {
|
||
try {
|
||
const ids = Object.keys(viewer.metaScene.metaModels);
|
||
ids.forEach((mid) => {
|
||
if (viewer.metaScene.destroyMetaModel) {
|
||
viewer.metaScene.destroyMetaModel(mid);
|
||
} else {
|
||
delete viewer.metaScene.metaModels[mid];
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.warn("metaScene cleanup failed", err);
|
||
}
|
||
}
|
||
setMeasureActive(false);
|
||
gizmo?.clearTarget();
|
||
treeView?.unShowNode?.();
|
||
clearMeasurements();
|
||
clearSectionPlanes();
|
||
selectionState = null;
|
||
lastSelection = { ids: [], modelId: null };
|
||
activeTransformTarget = null;
|
||
currentSelection = null;
|
||
if (!preserveMeta) {
|
||
currentProjectMeta = null;
|
||
lastAttemptedProjectId = null;
|
||
if (projectNameInput) {
|
||
projectNameInput.value = "";
|
||
projectNameInput.disabled = true;
|
||
}
|
||
}
|
||
updateNavCubeVisibility();
|
||
updatePanelsVisibility();
|
||
};
|
||
|
||
const getModelLabel = (modelId) => {
|
||
const entry = activeModels.find((m) => m?.model?.id === modelId);
|
||
if (!entry) return modelId;
|
||
return entry?.meta?.label || entry?.name || modelId;
|
||
};
|
||
|
||
const rebuildTreeModels = () => {
|
||
if (!viewer || !sdkRef) return;
|
||
treeModelIds.clear();
|
||
clearCustomTrees();
|
||
resetTreeView(sdkRef);
|
||
activeModels.forEach(({ model, name }) => {
|
||
const id = model?.id;
|
||
if (!id) return;
|
||
const metaPresent = hasMetaModel(id);
|
||
if (metaPresent && treeView?.addModel) {
|
||
try {
|
||
treeView.addModel(id, { rootName: name || id });
|
||
treeModelIds.add(id);
|
||
} catch (err) {
|
||
console.warn("tree addModel (rebuild)", err);
|
||
}
|
||
}
|
||
const hasNodes = treeContainer?.querySelector("li");
|
||
if (!metaPresent || !hasNodes) {
|
||
renderFallbackTree(id);
|
||
}
|
||
});
|
||
};
|
||
|
||
const deleteModelById = (modelId) => {
|
||
if (!modelId) return false;
|
||
const idx = activeModels.findIndex((m) => m?.model?.id === modelId);
|
||
if (idx === -1) return false;
|
||
const entry = activeModels[idx];
|
||
try {
|
||
entry.model?.destroy?.();
|
||
} catch (err) {
|
||
console.warn("destroy model failed", err);
|
||
}
|
||
try {
|
||
entry.loader?.destroy?.();
|
||
} catch (err) {
|
||
console.warn("destroy loader failed", err);
|
||
}
|
||
activeModels.splice(idx, 1);
|
||
delete sceneModels[modelId];
|
||
delete modelTypes[modelId];
|
||
pointCloudColorSnapshots.delete(modelId);
|
||
delete transformStore[modelId];
|
||
treeModelIds.delete(modelId);
|
||
if (customTrees.has(modelId)) {
|
||
try {
|
||
customTrees.get(modelId)?.remove?.();
|
||
} catch (_) {}
|
||
customTrees.delete(modelId);
|
||
}
|
||
if (viewer?.metaScene?.metaModels?.[modelId]) {
|
||
if (viewer.metaScene.destroyMetaModel) {
|
||
try {
|
||
viewer.metaScene.destroyMetaModel(modelId);
|
||
} catch (err) {
|
||
console.warn("meta destroy failed", err);
|
||
}
|
||
} else {
|
||
delete viewer.metaScene.metaModels[modelId];
|
||
}
|
||
}
|
||
if (selectionState?.modelId === modelId) {
|
||
selectionState = null;
|
||
updateTransformInputs(null);
|
||
updateGlobalInputs(null);
|
||
}
|
||
if (lastSelection?.modelId === modelId) {
|
||
lastSelection = { ids: [], modelId: null };
|
||
}
|
||
if (gizmo?.target?.modelId === modelId) {
|
||
gizmo.clearTarget();
|
||
}
|
||
activeTransformTarget = null;
|
||
currentSelection = null;
|
||
rebuildTreeModels();
|
||
updateNavCubeVisibility();
|
||
updatePanelsVisibility();
|
||
viewer?.scene?.setAABBDirty?.();
|
||
viewer?.scene?.glRedraw?.();
|
||
setStatus(`Модель ${modelId} удалена`);
|
||
return true;
|
||
};
|
||
|
||
const selectObject = ({ modelId, objectId }) => {
|
||
if (!viewer) return;
|
||
setSelection(objectId ? [objectId] : [], modelId);
|
||
if (objectId && treeView?.showNode) {
|
||
treeView.showNode(objectId);
|
||
}
|
||
};
|
||
|
||
const resetTreeView = (sdk) => {
|
||
if (treeView?.destroy) treeView.destroy();
|
||
treeView = new sdk.TreeViewPlugin(viewer, {
|
||
containerElement: treeContainer,
|
||
hierarchy: "containment",
|
||
autoAddModels: false,
|
||
pruneEmptyNodes: false,
|
||
autoExpandDepth: 2,
|
||
});
|
||
if (treeView?.on) {
|
||
treeView.on("nodeTitleClicked", (e) => {
|
||
const node = e?.treeViewNode || e?.node || e;
|
||
console.info("[DC Tree] nodeTitleClicked", {
|
||
nodeId: node?.nodeId || node?.id,
|
||
title: node?.title || node?.text || node?.name,
|
||
detail: e?.event?.detail
|
||
});
|
||
const ids = collectObjectIds(node);
|
||
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
|
||
setSelection(ids, modelId);
|
||
const clickDetail = (e?.event?.detail || e?.detail || 1);
|
||
if (clickDetail >= 2) {
|
||
flyToObjectIds(ids, modelId);
|
||
}
|
||
highlightTreeNode(node, { fromTree: true });
|
||
});
|
||
treeView.on("nodeTitleDoubleClicked", (e) => {
|
||
const node = e?.treeViewNode || e?.node || e;
|
||
console.info("[DC Tree] nodeTitleDoubleClicked", {
|
||
nodeId: node?.nodeId || node?.id,
|
||
title: node?.title || node?.text || node?.name
|
||
});
|
||
const ids = collectObjectIds(node);
|
||
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
|
||
highlightTreeNode(node, { fromTree: true });
|
||
flyToObjectIds(ids, modelId);
|
||
});
|
||
treeView.on("nodeDoubleClicked", (e) => {
|
||
const node = e?.treeViewNode || e?.node || e;
|
||
console.info("[DC Tree] nodeDoubleClicked", {
|
||
nodeId: node?.nodeId || node?.id,
|
||
title: node?.title || node?.text || node?.name
|
||
});
|
||
const ids = collectObjectIds(node);
|
||
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
|
||
highlightTreeNode(node, { fromTree: true });
|
||
flyToObjectIds(ids, modelId);
|
||
});
|
||
}
|
||
};
|
||
|
||
const createBlobSafeGLTFDataSource = () => ({
|
||
cacheBuster: false,
|
||
getGLTF: (src, ok, error) => {
|
||
if (src.startsWith("memory:") && memoryStore.has(src)) {
|
||
ok(memoryStore.get(src));
|
||
return;
|
||
}
|
||
fetch(src)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.arrayBuffer();
|
||
})
|
||
.then(ok)
|
||
.catch((err) => error(err.message || err));
|
||
},
|
||
getGLB: (src, ok, error) => {
|
||
if (src.startsWith("memory:") && memoryStore.has(src)) {
|
||
ok(memoryStore.get(src));
|
||
return;
|
||
}
|
||
fetch(src)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.arrayBuffer();
|
||
})
|
||
.then(ok)
|
||
.catch((err) => error(err.message || err));
|
||
},
|
||
getMetaModel: (src, ok, error) => {
|
||
if (src.startsWith("memory:") && memoryStore.has(src)) {
|
||
try {
|
||
const buffer = memoryStore.get(src);
|
||
const text = new TextDecoder().decode(new Uint8Array(buffer));
|
||
ok(JSON.parse(text));
|
||
} catch (err) {
|
||
error(err.message || err);
|
||
}
|
||
return;
|
||
}
|
||
fetch(src)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json();
|
||
})
|
||
.then(ok)
|
||
.catch((err) => error(err.message || err));
|
||
},
|
||
});
|
||
|
||
const createMemoryDataSource = () => ({
|
||
cacheBuster: false,
|
||
getXKT: (src, ok, error) => {
|
||
if (memoryStore.has(src)) {
|
||
const stored = memoryStore.get(src);
|
||
const cloned = cloneToUint8(stored);
|
||
ok(cloned ? cloned.buffer : stored);
|
||
return;
|
||
}
|
||
fetch(src)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.arrayBuffer();
|
||
})
|
||
.then(ok)
|
||
.catch((err) => error(err.message || err));
|
||
},
|
||
getGLTF: (src, ok, error) => {
|
||
if (memoryStore.has(src)) {
|
||
const stored = memoryStore.get(src);
|
||
const cloned = cloneToUint8(stored);
|
||
ok(cloned ? cloned.buffer : stored);
|
||
return;
|
||
}
|
||
fetch(src)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.arrayBuffer();
|
||
})
|
||
.then(ok)
|
||
.catch((err) => error(err.message || err));
|
||
},
|
||
getGLB: (src, ok, error) => {
|
||
if (memoryStore.has(src)) {
|
||
const stored = memoryStore.get(src);
|
||
const cloned = cloneToUint8(stored);
|
||
ok(cloned ? cloned.buffer : stored);
|
||
return;
|
||
}
|
||
fetch(src)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.arrayBuffer();
|
||
})
|
||
.then(ok)
|
||
.catch((err) => error(err.message || err));
|
||
},
|
||
getLAS: (src, ok, error) => {
|
||
if (memoryStore.has(src)) {
|
||
const stored = memoryStore.get(src);
|
||
const cloned = cloneToUint8(stored);
|
||
ok(cloned ? cloned.buffer : stored);
|
||
return;
|
||
}
|
||
fetch(src)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.arrayBuffer();
|
||
})
|
||
.then(ok)
|
||
.catch((err) => error(err.message || err));
|
||
},
|
||
getMetaModel: (src, ok, error) => {
|
||
if (memoryStore.has(src)) {
|
||
try {
|
||
const stored = memoryStore.get(src);
|
||
const bytes = cloneToUint8(stored) || new Uint8Array(0);
|
||
const text = new TextDecoder().decode(bytes);
|
||
ok(JSON.parse(text));
|
||
} catch (err) {
|
||
error(err.message || err);
|
||
}
|
||
return;
|
||
}
|
||
fetch(src)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json();
|
||
})
|
||
.then(ok)
|
||
.catch((err) => error(err.message || err));
|
||
},
|
||
getSTL: (src, ok, error) => {
|
||
if (memoryStore.has(src)) {
|
||
const stored = memoryStore.get(src);
|
||
const cloned = cloneToUint8(stored);
|
||
ok(cloned ? cloned.buffer : stored);
|
||
return;
|
||
}
|
||
fetch(src)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.arrayBuffer();
|
||
})
|
||
.then(ok)
|
||
.catch((err) => error(err.message || err));
|
||
},
|
||
});
|
||
|
||
const loaderFor = (sdk, type, { cacheBuster = true, dataSource } = {}) => {
|
||
switch (type) {
|
||
case "xkt":
|
||
return new sdk.XKTLoaderPlugin(viewer, {
|
||
dataSource: dataSource || new sdk.XKTDefaultDataSource({ cacheBuster }),
|
||
});
|
||
case "gltf":
|
||
return new sdk.GLTFLoaderPlugin(viewer, {
|
||
dataSource: dataSource || new sdk.GLTFDefaultDataSource({ cacheBuster }),
|
||
});
|
||
case "bim":
|
||
return new sdk.DotBIMLoaderPlugin(viewer, {
|
||
dataSource: dataSource || new sdk.DotBIMDefaultDataSource({ cacheBuster }),
|
||
});
|
||
case "las":
|
||
return new sdk.LASLoaderPlugin(viewer, {
|
||
fp64: true,
|
||
dataSource: dataSource,
|
||
});
|
||
case "obj":
|
||
return new sdk.OBJLoaderPlugin(viewer);
|
||
case "stl":
|
||
return new sdk.STLLoaderPlugin(viewer, {
|
||
dataSource: dataSource || new sdk.STLDefaultDataSource({ cacheBuster }),
|
||
});
|
||
default:
|
||
throw new Error(`Неподдерживаемый тип: ${type}`);
|
||
}
|
||
};
|
||
|
||
const guessTypeFromName = (name) => {
|
||
if (!name) return null;
|
||
const lower = name.toLowerCase();
|
||
if (lower.endsWith(".xkt")) return "xkt";
|
||
if (lower.endsWith(".glb") || lower.endsWith(".gltf")) return "gltf";
|
||
if (lower.endsWith(".bim")) return "bim";
|
||
if (lower.endsWith(".las") || lower.endsWith(".laz")) return "las";
|
||
if (lower.endsWith(".obj")) return "obj";
|
||
if (lower.endsWith(".stl")) return "stl";
|
||
return null;
|
||
};
|
||
|
||
const isApiRequestUrl = (url) => {
|
||
try {
|
||
return new URL(url, window.location.href).pathname.startsWith("/api/");
|
||
} catch (_) {
|
||
return String(url || "").startsWith("/api/");
|
||
}
|
||
};
|
||
|
||
const formatFetchFailureMessage = (url, res, text, data) => {
|
||
const apiRequest = isApiRequestUrl(url);
|
||
if (apiRequest && res.status === 401) {
|
||
return "BIM API не принял запрос: нужна авторизация.";
|
||
}
|
||
if (apiRequest && res.status === 404) {
|
||
return "BIM API недоступен на этом адресе. Открой viewer через node server/index.js, не через статический http-server.";
|
||
}
|
||
if (apiRequest && /^\s*<!doctype html/i.test(text || "")) {
|
||
return `BIM API вернул HTML вместо JSON (HTTP ${res.status}). Проверь, что открыт backend-сервер BIM Viewer.`;
|
||
}
|
||
return (data && (data.message || data.error)) || text || `HTTP ${res.status}`;
|
||
};
|
||
|
||
const formatApiNonJsonMessage = (url, res, text) => {
|
||
const status = res?.status ? `HTTP ${res.status}` : "HTTP";
|
||
if (/^\s*<!doctype html/i.test(text || "")) {
|
||
return `BIM API на этом адресе вернул HTML вместо JSON (${status}). На localhost:8080 сейчас нет backend /api/comments.`;
|
||
}
|
||
return `BIM API вернул не-JSON ответ (${status}).`;
|
||
};
|
||
|
||
const fetchJSON = async (url, options = {}) => {
|
||
const apiRequest = isApiRequestUrl(url);
|
||
let res = null;
|
||
try {
|
||
res = await fetch(url, options);
|
||
} catch (err) {
|
||
if (apiRequest) {
|
||
throw new Error(`BIM API недоступен: ${err?.message || "network error"}`);
|
||
}
|
||
throw err;
|
||
}
|
||
const text = await res.text();
|
||
let data = null;
|
||
try {
|
||
data = text ? JSON.parse(text) : null;
|
||
} catch (err) {
|
||
// ignore parse errors, text will be used for message
|
||
}
|
||
if (!res.ok) {
|
||
const message = formatFetchFailureMessage(url, res, text, data);
|
||
throw new Error(message);
|
||
}
|
||
if (apiRequest && text && !data) {
|
||
throw new Error(formatApiNonJsonMessage(url, res, text));
|
||
}
|
||
return data;
|
||
};
|
||
|
||
const fetchJSONWithTimeout = async (url, options = {}, timeoutMs = 5000) => {
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
return await fetchJSON(url, {
|
||
...options,
|
||
signal: options.signal || controller.signal,
|
||
});
|
||
} finally {
|
||
window.clearTimeout(timer);
|
||
}
|
||
};
|
||
|
||
const uploadBuffer = async (bytes, filename, options = {}) => {
|
||
const safeName = filename || "upload.bin";
|
||
const params = new URLSearchParams({ filename: safeName });
|
||
const projectId = options.projectId || currentProjectMeta?.id;
|
||
if (projectId) params.set("projectId", projectId);
|
||
if (options.assetId) params.set("assetId", options.assetId);
|
||
if (options.version) params.set("version", String(options.version));
|
||
const res = await fetch(`/api/uploads?${params.toString()}`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/octet-stream" },
|
||
body: bytes
|
||
});
|
||
const text = await res.text();
|
||
let data = null;
|
||
try {
|
||
data = text ? JSON.parse(text) : null;
|
||
} catch (_) {
|
||
data = null;
|
||
}
|
||
if (!res.ok) {
|
||
throw new Error((data && data.message) || text || `Upload failed (${res.status})`);
|
||
}
|
||
if (!data || !data.src) {
|
||
throw new Error("Upload response is missing src");
|
||
}
|
||
return data;
|
||
};
|
||
|
||
const getVersionKey = (version) => version?.versionId || `version-${version?.version || "unknown"}`;
|
||
|
||
const getModelVersionIdentity = (entry = getPrimaryModelEntry()) => {
|
||
const meta = entry?.meta || {};
|
||
const direct = {
|
||
projectId: meta.projectId || meta.conversion?.projectId || null,
|
||
assetId: meta.assetId || meta.conversion?.assetId || null,
|
||
};
|
||
if (direct.projectId && direct.assetId) return direct;
|
||
const candidates = [
|
||
meta.sourceSrc,
|
||
meta.settingsSrc,
|
||
meta.artifactSrc,
|
||
meta.conversion?.sourceSrc,
|
||
meta.conversion?.artifactSrc,
|
||
entry?.src,
|
||
];
|
||
for (const candidate of candidates) {
|
||
const identity = extractUploadIdentityFromSrc(candidate);
|
||
if (identity.projectId && identity.assetId) return identity;
|
||
}
|
||
return direct;
|
||
};
|
||
|
||
const normalizeVersionSource = (value) => {
|
||
if (!value || typeof value !== "string") return "";
|
||
const normalized = value.trim();
|
||
if (normalized.startsWith("uploads/")) return `/${normalized}`;
|
||
try {
|
||
return new URL(normalized, window.location.href).toString();
|
||
} catch (_) {
|
||
return normalized;
|
||
}
|
||
};
|
||
|
||
const getVersionViewerSrc = (version) => {
|
||
if (!version?.previewAvailable) return "";
|
||
return normalizeVersionSource(version.src || version.viewerUrl || version.downloadUrl || "");
|
||
};
|
||
|
||
const getVersionSettingsSrc = (version) => (
|
||
version?.sourceSrc ||
|
||
version?.conversion?.sourceSrc ||
|
||
version?.downloadUrl ||
|
||
version?.src ||
|
||
""
|
||
);
|
||
|
||
const getVersionViewerType = (version) => {
|
||
const src = getVersionViewerSrc(version);
|
||
return normalizeType(version?.type, src) || normalizeType(guessTypeFromName(src || version?.originalFilename || ""));
|
||
};
|
||
|
||
const getCurrentVersionIdForEntry = (entry = getPrimaryModelEntry()) => {
|
||
const meta = entry?.meta || {};
|
||
return meta.versionId || meta.conversion?.versionId || modelVersionHistory?.currentVersionId || null;
|
||
};
|
||
|
||
const isCurrentVersionRecord = (version, entry = getPrimaryModelEntry()) => {
|
||
const currentVersionId = getCurrentVersionIdForEntry(entry);
|
||
if (currentVersionId && version?.versionId) return currentVersionId === version.versionId;
|
||
const currentVersion = entry?.meta?.version || modelVersionHistory?.currentVersion;
|
||
return Number(currentVersion) === Number(version?.version);
|
||
};
|
||
|
||
const formatVersionOptionLabel = (version) => {
|
||
const number = Number(version?.version);
|
||
const prefix = Number.isFinite(number) ? `v${number}` : "Версия";
|
||
const name = version?.originalFilename || "model";
|
||
return `${prefix} · ${name}`;
|
||
};
|
||
|
||
const buildVersionHistoryParams = (entry = getPrimaryModelEntry()) => {
|
||
const identity = getModelVersionIdentity(entry);
|
||
const params = new URLSearchParams();
|
||
if (identity.projectId && identity.assetId) {
|
||
params.set("projectId", identity.projectId);
|
||
params.set("assetId", identity.assetId);
|
||
return params;
|
||
}
|
||
const src = getModelSettingsSrc(entry) || entry?.meta?.sourceSrc || entry?.meta?.artifactSrc || entry?.src || "";
|
||
if (src) params.set("src", src);
|
||
return params;
|
||
};
|
||
|
||
const setEntryVersionHistory = (entry, history) => {
|
||
if (!entry || !history) return;
|
||
const versions = Array.isArray(history.versions) ? history.versions : [];
|
||
const meta = entry.meta || {};
|
||
entry.meta = {
|
||
...meta,
|
||
assetId: history.assetId || meta.assetId || null,
|
||
projectId: history.projectId || meta.projectId || null,
|
||
versionId: meta.versionId || history.currentVersionId || null,
|
||
version: meta.version || history.currentVersion || null,
|
||
versions,
|
||
};
|
||
};
|
||
|
||
const fetchVersionHistoryForEntry = async (entry = getPrimaryModelEntry()) => {
|
||
const params = buildVersionHistoryParams(entry);
|
||
if (!params.toString()) return null;
|
||
return fetchJSON(`${UPLOAD_VERSIONS_API}?${params.toString()}`);
|
||
};
|
||
|
||
const updateModelVersionControl = () => {
|
||
const entry = getPrimaryModelEntry();
|
||
const versions = modelVersionHistory?.versions || entry?.meta?.versions || [];
|
||
const identity = getModelVersionIdentity(entry);
|
||
const hasControl = !!entry && (!!identity.assetId || versions.length > 0);
|
||
modelVersionControl?.classList.toggle("hidden", !hasControl);
|
||
if (!hasControl) return;
|
||
|
||
const currentVersionId = getCurrentVersionIdForEntry(entry);
|
||
const currentRecord = versions.find((version) => currentVersionId && version?.versionId === currentVersionId) ||
|
||
versions.find((version) => Number(version?.version) === Number(entry?.meta?.version)) ||
|
||
versions[versions.length - 1];
|
||
const currentValue = currentRecord ? getVersionKey(currentRecord) :
|
||
currentVersionId ||
|
||
"";
|
||
const options = versions.length
|
||
? versions.map((version) => ({ value: getVersionKey(version), label: formatVersionOptionLabel(version) }))
|
||
: [{ value: "loading", label: modelVersionHistoryLoading ? "Загружаем версии..." : "Версии не найдены" }];
|
||
|
||
if (modelVersionSelect) {
|
||
modelVersionSelect.setOptions(options, currentValue || options[0]?.value);
|
||
}
|
||
if (modelVersionHistoryButton) {
|
||
modelVersionHistoryButton.disabled = modelVersionHistoryLoading || !versions.length;
|
||
}
|
||
if (modelVersionUploadButton) {
|
||
modelVersionUploadButton.disabled = isGuestMode() || modelVersionUploading || !identity.assetId || !identity.projectId;
|
||
modelVersionUploadButton.title = modelVersionUploading ? "Загружаем версию..." : "Добавить версию";
|
||
}
|
||
};
|
||
|
||
const refreshModelVersionHistory = async ({ silent = false } = {}) => {
|
||
const entry = getPrimaryModelEntry();
|
||
if (!entry) {
|
||
modelVersionHistory = null;
|
||
updateModelVersionControl();
|
||
return null;
|
||
}
|
||
const params = buildVersionHistoryParams(entry);
|
||
if (!params.toString()) {
|
||
updateModelVersionControl();
|
||
return null;
|
||
}
|
||
modelVersionHistoryLoading = true;
|
||
updateModelVersionControl();
|
||
try {
|
||
const history = await fetchVersionHistoryForEntry(entry);
|
||
modelVersionHistory = history;
|
||
setEntryVersionHistory(entry, history);
|
||
updateModelVersionControl();
|
||
return history;
|
||
} catch (err) {
|
||
if (!silent) console.warn("model version history unavailable", err);
|
||
updateModelVersionControl();
|
||
return null;
|
||
} finally {
|
||
modelVersionHistoryLoading = false;
|
||
updateModelVersionControl();
|
||
}
|
||
};
|
||
|
||
const openModelVersion = async (version) => {
|
||
const viewerSrc = getVersionViewerSrc(version);
|
||
const viewerType = getVersionViewerType(version);
|
||
if (!viewerSrc || !viewerType) {
|
||
setStatus("Эта версия еще не готова к просмотру");
|
||
return;
|
||
}
|
||
const previousEntry = getPrimaryModelEntry();
|
||
const identity = getModelVersionIdentity(previousEntry);
|
||
const settingsSrc = getVersionSettingsSrc(version);
|
||
const meta = {
|
||
...(previousEntry?.meta || {}),
|
||
assetId: version.assetId || identity.assetId || previousEntry?.meta?.assetId || null,
|
||
projectId: version.projectId || identity.projectId || previousEntry?.meta?.projectId || null,
|
||
version: version.version || null,
|
||
versionId: version.versionId || null,
|
||
versions: modelVersionHistory?.versions || previousEntry?.meta?.versions || [],
|
||
label: version.originalFilename || previousEntry?.name || "model",
|
||
sourceSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc,
|
||
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc,
|
||
artifactSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc,
|
||
viewerSettings: null,
|
||
};
|
||
const settings = await fetchModelSettings(settingsSrc).catch(() => null);
|
||
if (settings?.viewerSettings) {
|
||
meta.viewerSettings = settings.viewerSettings;
|
||
}
|
||
await loadModelAsync({
|
||
type: viewerType,
|
||
url: viewerSrc,
|
||
name: version.originalFilename || previousEntry?.name || "model",
|
||
replace: true,
|
||
id: previousEntry?.model?.id || "model",
|
||
meta,
|
||
});
|
||
modelVersionHistory = {
|
||
...(modelVersionHistory || {}),
|
||
currentVersion: version.version || modelVersionHistory?.currentVersion || null,
|
||
currentVersionId: version.versionId || modelVersionHistory?.currentVersionId || null,
|
||
versions: modelVersionHistory?.versions || meta.versions || [],
|
||
};
|
||
updateModelVersionControl();
|
||
};
|
||
|
||
const uploadModelVersion = async (file) => {
|
||
const entry = getPrimaryModelEntry();
|
||
const identity = getModelVersionIdentity(entry);
|
||
if (!file || !entry || !identity.projectId || !identity.assetId || isGuestMode()) return;
|
||
const detected = guessTypeFromName(file.name);
|
||
const uploadOnly = /\.stp?$/i.test(file.name);
|
||
if (!detected && !uploadOnly) {
|
||
showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl, .step или .stp");
|
||
return;
|
||
}
|
||
const versions = modelVersionHistory?.versions || entry.meta?.versions || [];
|
||
const nextVersion = Math.max(0, ...versions.map((version) => Number(version?.version) || 0), Number(entry.meta?.version) || 0) + 1;
|
||
modelVersionUploading = true;
|
||
updateModelVersionControl();
|
||
try {
|
||
const bytes = cloneToUint8(await file.arrayBuffer()) || new Uint8Array(0);
|
||
const uploaded = await uploadBuffer(bytes, file.name, {
|
||
projectId: identity.projectId,
|
||
assetId: identity.assetId,
|
||
version: nextVersion,
|
||
});
|
||
uploadCache.set(entry.src || file.name, uploaded.src);
|
||
const history = await refreshModelVersionHistory({ silent: true });
|
||
const newVersion = history?.versions?.find((version) => version.versionId === uploaded.versionId) ||
|
||
history?.versions?.find((version) => Number(version.version) === Number(uploaded.version));
|
||
if (newVersion?.previewAvailable) {
|
||
await openModelVersion(newVersion);
|
||
} else {
|
||
setStatus(`Версия v${uploaded.version || nextVersion} загружена. Просмотр появится после подготовки модели.`);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
showError(err.message || "Не удалось загрузить версию модели");
|
||
} finally {
|
||
modelVersionUploading = false;
|
||
updateModelVersionControl();
|
||
}
|
||
};
|
||
|
||
const closeVersionHistoryModal = () => {
|
||
versionHistoryModal?.backdrop?.remove();
|
||
versionHistoryModal?.modal?.remove();
|
||
versionHistoryModal = null;
|
||
};
|
||
|
||
const renderVersionHistoryModal = () => {
|
||
if (!versionHistoryModal) return;
|
||
const entry = getPrimaryModelEntry();
|
||
const versions = modelVersionHistory?.versions || entry?.meta?.versions || [];
|
||
const { body, title, subtitle } = versionHistoryModal;
|
||
title.textContent = "История версий";
|
||
subtitle.textContent = entry?.name || entry?.meta?.label || modelVersionHistory?.originalFilename || "model";
|
||
body.replaceChildren();
|
||
if (!versions.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "version-history-empty";
|
||
empty.textContent = modelVersionHistoryLoading ? "Загружаем версии..." : "История версий пока не создана.";
|
||
body.appendChild(empty);
|
||
return;
|
||
}
|
||
const table = document.createElement("div");
|
||
table.className = "version-history-table";
|
||
const header = document.createElement("div");
|
||
header.className = "version-history-row version-history-row--head";
|
||
["Файл", "Версия", "Размер", "Автор", "Дата", "Статус", "Действия"].forEach((label) => {
|
||
const cell = document.createElement("div");
|
||
cell.textContent = label;
|
||
if (label === "Действия") cell.style.textAlign = "right";
|
||
header.appendChild(cell);
|
||
});
|
||
table.appendChild(header);
|
||
[...versions].sort((a, b) => Number(b.version || 0) - Number(a.version || 0)).forEach((version) => {
|
||
const row = document.createElement("div");
|
||
row.className = "version-history-row";
|
||
const fileCell = document.createElement("div");
|
||
fileCell.className = "version-history-file";
|
||
const fileName = document.createElement("strong");
|
||
fileName.textContent = version.originalFilename || "model";
|
||
fileCell.appendChild(fileName);
|
||
if (version.sha256) {
|
||
const sha = document.createElement("span");
|
||
sha.textContent = `sha256 ${version.sha256}`;
|
||
fileCell.appendChild(sha);
|
||
}
|
||
row.appendChild(fileCell);
|
||
|
||
const versionCell = document.createElement("div");
|
||
versionCell.className = "version-history-cell";
|
||
versionCell.textContent = Number.isFinite(Number(version.version)) ? `v${version.version}` : "—";
|
||
if (isCurrentVersionRecord(version, entry)) {
|
||
const current = document.createElement("span");
|
||
current.className = "version-history-current";
|
||
current.textContent = "текущая";
|
||
versionCell.appendChild(current);
|
||
}
|
||
row.appendChild(versionCell);
|
||
|
||
const sizeCell = document.createElement("div");
|
||
sizeCell.className = "version-history-cell";
|
||
sizeCell.textContent = formatFileSize(version.size) || "—";
|
||
row.appendChild(sizeCell);
|
||
|
||
const authorCell = document.createElement("div");
|
||
authorCell.className = "version-history-cell";
|
||
authorCell.textContent = version.uploadedBy || "—";
|
||
row.appendChild(authorCell);
|
||
|
||
const dateCell = document.createElement("div");
|
||
dateCell.className = "version-history-cell";
|
||
dateCell.textContent = formatDateTime(version.uploadedAt);
|
||
row.appendChild(dateCell);
|
||
|
||
const statusCell = document.createElement("div");
|
||
statusCell.className = "version-history-cell";
|
||
statusCell.textContent = version.status === "ready"
|
||
? "Модель готова"
|
||
: version.status === "failed"
|
||
? "Ошибка подготовки"
|
||
: "Ожидает подготовки";
|
||
row.appendChild(statusCell);
|
||
|
||
const actions = document.createElement("div");
|
||
actions.className = "version-history-actions";
|
||
const viewButton = document.createElement("button");
|
||
viewButton.type = "button";
|
||
viewButton.className = "inspector-icon-btn";
|
||
viewButton.title = "Посмотреть";
|
||
viewButton.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M1.8 8s2.1-4.1 6.2-4.1S14.2 8 14.2 8 12.1 12.1 8 12.1 1.8 8 1.8 8Z"></path><circle cx="8" cy="8" r="1.7"></circle></svg>`;
|
||
viewButton.disabled = !getVersionViewerSrc(version);
|
||
viewButton.addEventListener("click", () => {
|
||
closeVersionHistoryModal();
|
||
openModelVersion(version);
|
||
});
|
||
actions.appendChild(viewButton);
|
||
if (version.downloadUrl) {
|
||
const download = document.createElement("a");
|
||
download.className = "inspector-icon-btn";
|
||
download.href = version.downloadUrl;
|
||
download.target = "_blank";
|
||
download.rel = "noopener noreferrer";
|
||
download.download = version.originalFilename || "model";
|
||
download.title = "Скачать";
|
||
download.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2.8v7.1"></path><path d="m5.3 7.2 2.7 2.7 2.7-2.7"></path><path d="M3 12.8h10"></path></svg>`;
|
||
actions.appendChild(download);
|
||
}
|
||
const deleteButton = document.createElement("button");
|
||
deleteButton.type = "button";
|
||
deleteButton.className = "inspector-icon-btn";
|
||
deleteButton.title = "Удалить версию";
|
||
deleteButton.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.2 4.5h9.6"></path><path d="M6.2 4.5V3.1h3.6v1.4"></path><path d="m4.4 4.5.55 8.4h6.1l.55-8.4"></path><path d="M6.65 6.5v4.3"></path><path d="M9.35 6.5v4.3"></path></svg>`;
|
||
deleteButton.disabled = isGuestMode() || isCurrentVersionRecord(version, entry);
|
||
deleteButton.addEventListener("click", async () => {
|
||
deleteButton.disabled = true;
|
||
try {
|
||
await fetchJSON("/api/uploads/version", {
|
||
method: "DELETE",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(version),
|
||
});
|
||
await refreshModelVersionHistory({ silent: true });
|
||
renderVersionHistoryModal();
|
||
} catch (err) {
|
||
showError(err.message || "Не удалось удалить версию");
|
||
}
|
||
});
|
||
actions.appendChild(deleteButton);
|
||
row.appendChild(actions);
|
||
table.appendChild(row);
|
||
});
|
||
body.appendChild(table);
|
||
};
|
||
|
||
const openVersionHistoryModal = async () => {
|
||
if (versionHistoryModal) {
|
||
renderVersionHistoryModal();
|
||
return;
|
||
}
|
||
const backdrop = document.createElement("div");
|
||
backdrop.className = "version-history-backdrop";
|
||
const modal = document.createElement("div");
|
||
modal.className = "version-history-modal";
|
||
const header = document.createElement("div");
|
||
header.className = "version-history-header";
|
||
const titleWrap = document.createElement("div");
|
||
titleWrap.className = "version-history-title";
|
||
const title = document.createElement("strong");
|
||
const subtitle = document.createElement("span");
|
||
titleWrap.append(title, subtitle);
|
||
const close = document.createElement("button");
|
||
close.type = "button";
|
||
close.className = "inspector-icon-btn version-history-close";
|
||
close.title = "Закрыть";
|
||
close.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75"></path></svg>`;
|
||
header.append(titleWrap, close);
|
||
const body = document.createElement("div");
|
||
body.className = "version-history-body";
|
||
modal.append(header, body);
|
||
document.body.append(backdrop, modal);
|
||
versionHistoryModal = { backdrop, modal, title, subtitle, body };
|
||
close.addEventListener("click", closeVersionHistoryModal);
|
||
backdrop.addEventListener("click", closeVersionHistoryModal);
|
||
renderVersionHistoryModal();
|
||
await refreshModelVersionHistory({ silent: true });
|
||
renderVersionHistoryModal();
|
||
};
|
||
|
||
const loadProjectsList = async () => {
|
||
try {
|
||
const list = await fetchJSON(API_BASE);
|
||
const templates = Array.isArray(list) ? list.filter((item) => item.type === "template") : [];
|
||
const projects = Array.isArray(list) ? list.filter((item) => item.type !== "template") : [];
|
||
projectIndex = { templates, projects };
|
||
templatesMenu?.setData(projectIndex);
|
||
return projectIndex;
|
||
} catch (err) {
|
||
const fallback = { templates: templateFallbacks, projects: [] };
|
||
projectIndex = fallback;
|
||
templatesMenu?.setData({ ...fallback, error: err.message || "API недоступно" });
|
||
throw err;
|
||
}
|
||
};
|
||
|
||
const fetchProjectById = async (id) => {
|
||
if (!id) {
|
||
throw new Error("Не указан ID проекта");
|
||
}
|
||
return fetchJSON(`${API_BASE}/${id}`);
|
||
};
|
||
|
||
const findProjectMetaById = (id) => {
|
||
if (!id) return null;
|
||
const all = [...(projectIndex.templates || []), ...(projectIndex.projects || [])];
|
||
const found = all.find((p) => p.id === id);
|
||
if (found) {
|
||
return { id: found.id, name: found.name, type: found.type };
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const submitProjectRename = async () => {
|
||
if (isGuestMode()) return;
|
||
if (!projectNameInput || projectNameInput.disabled) return;
|
||
const newName = (projectNameInput.value || "").trim();
|
||
const targetId = currentProjectMeta?.id || lastAttemptedProjectId;
|
||
if (!targetId || !newName) {
|
||
if (!newName && projectNameError) projectNameError.textContent = "";
|
||
return;
|
||
}
|
||
if (projectNameError) projectNameError.textContent = "";
|
||
try {
|
||
const updated = await fetchJSON(`${API_BASE}/${targetId}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ name: newName })
|
||
});
|
||
currentProjectMeta = { ...(currentProjectMeta || {}), id: targetId, name: updated.name || newName, type: updated.type || currentProjectMeta?.type || "project" };
|
||
lastAttemptedProjectId = currentProjectMeta.id;
|
||
await loadProjectsList().catch(() => {});
|
||
setStatus("Название проекта обновлено");
|
||
} catch (err) {
|
||
console.error(err);
|
||
if (projectNameError) projectNameError.textContent = err.message || "Не удалось обновить название";
|
||
}
|
||
};
|
||
|
||
const safeClone = (value) => {
|
||
try {
|
||
return JSON.parse(JSON.stringify(value || {}));
|
||
} catch (err) {
|
||
return {};
|
||
}
|
||
};
|
||
|
||
const isRemoteSrc = (src) => {
|
||
if (!src || typeof src !== "string") return false;
|
||
return !(src.startsWith("memory:") || src.startsWith("blob:"));
|
||
};
|
||
|
||
const cloneToUint8 = (data) => {
|
||
if (!data) return new Uint8Array(0);
|
||
if (data instanceof Uint8Array) {
|
||
return data.slice();
|
||
}
|
||
if (data instanceof ArrayBuffer) {
|
||
try {
|
||
return new Uint8Array(data.slice(0));
|
||
} catch (err) {
|
||
return new Uint8Array(0);
|
||
}
|
||
}
|
||
try {
|
||
return new Uint8Array(data);
|
||
} catch (err) {
|
||
return new Uint8Array(0);
|
||
}
|
||
};
|
||
|
||
const arrayBufferToBase64 = (buffer) => {
|
||
const bytes = cloneToUint8(buffer);
|
||
let binary = "";
|
||
for (let i = 0; i < bytes.byteLength; i++) {
|
||
binary += String.fromCharCode(bytes[i]);
|
||
}
|
||
return btoa(binary);
|
||
};
|
||
|
||
const getBufferForSrc = async (src) => {
|
||
if (!src) return null;
|
||
if (memoryStore.has(src)) {
|
||
const stored = memoryStore.get(src);
|
||
const bytes = cloneToUint8(stored);
|
||
return bytes;
|
||
}
|
||
try {
|
||
const res = await fetch(src);
|
||
if (!res.ok) return null;
|
||
const buf = await res.arrayBuffer();
|
||
return cloneToUint8(buf);
|
||
} catch (err) {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const snapshotVec = (v) => {
|
||
if (!v) return null;
|
||
if (Array.isArray(v)) return [...v];
|
||
if (ArrayBuffer.isView(v)) return Array.from(v);
|
||
return null;
|
||
};
|
||
|
||
const buildProjectSnapshot = async (name) => {
|
||
const models = [];
|
||
|
||
const transformsCopy = safeClone(transformStore);
|
||
activeModels.forEach(({ model }) => {
|
||
if (!model || !model.id) return;
|
||
if (!transformsCopy[model.id]) transformsCopy[model.id] = {};
|
||
if (!transformsCopy[model.id]["__model__"]) {
|
||
transformsCopy[model.id]["__model__"] = {
|
||
position: snapshotVec(model.position) || [0, 0, 0],
|
||
rotation: snapshotVec(model.rotation) || [0, 0, 0],
|
||
scale: snapshotVec(model.scale) || [1, 1, 1],
|
||
};
|
||
}
|
||
});
|
||
|
||
for (const entry of activeModels) {
|
||
const model = entry.model;
|
||
const modelType = normalizeType(entry.type, entry.src) || normalizeType(guessTypeFromName(entry.src)) || entry.type || null;
|
||
const src = typeof entry.src === "string" ? entry.src : "";
|
||
const meta = entry.meta || {};
|
||
const versionIdentity = getModelVersionIdentity(entry);
|
||
const baseModel = {
|
||
id: model.id,
|
||
type: modelType,
|
||
meta: {
|
||
label: meta.label || entry.name || model.id,
|
||
assetId: meta.assetId || meta.conversion?.assetId || versionIdentity.assetId || null,
|
||
projectId: meta.projectId || meta.conversion?.projectId || versionIdentity.projectId || null,
|
||
version: meta.version || null,
|
||
versionId: meta.versionId || meta.conversion?.versionId || null,
|
||
versions: Array.isArray(meta.versions) ? meta.versions : [],
|
||
metadataPath: meta.metadataPath || null,
|
||
settingsSrc: getModelSettingsSrc(entry) || null,
|
||
sourceSrc: meta.sourceSrc || meta.conversion?.sourceSrc || meta.manifest?.sourceSrc || null,
|
||
artifactSrc: meta.artifactSrc || null
|
||
},
|
||
visible: model.visible !== false
|
||
};
|
||
|
||
if (!modelType) {
|
||
throw new Error("Не удалось определить тип модели для сохранения.");
|
||
}
|
||
|
||
if (isRemoteSrc(src)) {
|
||
models.push({ ...baseModel, src });
|
||
continue;
|
||
}
|
||
|
||
const cacheKey = src || `${model.id}-${modelType}`;
|
||
if (uploadCache.has(cacheKey)) {
|
||
models.push({ ...baseModel, src: uploadCache.get(cacheKey) });
|
||
continue;
|
||
}
|
||
|
||
const buffer = await getBufferForSrc(src);
|
||
if (!buffer) {
|
||
throw new Error("Нельзя сохранить проект: отсутствуют данные загруженного файла (blob/memory).");
|
||
}
|
||
const bytes = cloneToUint8(buffer);
|
||
const filename = (entry.name && entry.name.includes(".")) ? entry.name : `${model.id || "model"}.${modelType || "bin"}`;
|
||
const uploaded = await uploadBuffer(bytes, filename);
|
||
const uploadedSrc = uploaded.src;
|
||
uploadCache.set(cacheKey, uploadedSrc);
|
||
models.push({
|
||
...baseModel,
|
||
src: uploadedSrc,
|
||
meta: {
|
||
...baseModel.meta,
|
||
assetId: uploaded.assetId || baseModel.meta.assetId || null,
|
||
projectId: uploaded.projectId || baseModel.meta.projectId || null,
|
||
version: uploaded.version || baseModel.meta.version || null,
|
||
versionId: uploaded.versionId || baseModel.meta.versionId || null,
|
||
sourceSrc: uploaded.src || baseModel.meta.sourceSrc || null,
|
||
settingsSrc: uploaded.src || baseModel.meta.settingsSrc || null,
|
||
}
|
||
});
|
||
}
|
||
|
||
const viewerState = viewer ? {
|
||
camera: viewer.camera ? {
|
||
eye: snapshotVec(viewer.camera.eye),
|
||
look: snapshotVec(viewer.camera.look),
|
||
up: snapshotVec(viewer.camera.up),
|
||
projection: viewer.camera.projection
|
||
} : null,
|
||
transparent: !!toggleTransparent?.checked,
|
||
edges: !!toggleEdges?.checked,
|
||
addMode: !!addMode?.checked,
|
||
displayMode: activeDisplayMode,
|
||
customDisplayColor: activeCustomDisplayColor,
|
||
customDisplaySaturation: activeCustomDisplaySaturation,
|
||
lightingMode: activeLightingMode,
|
||
navigationAxis: activeNavigationAxis,
|
||
zoomSpeed: activeZoomSpeed,
|
||
selection: {
|
||
ids: lastSelection.ids || [],
|
||
modelId: lastSelection.modelId || null,
|
||
},
|
||
models: activeModels.map((entry) => ({
|
||
id: entry.model?.id,
|
||
visible: entry.model?.visible !== false
|
||
})),
|
||
transforms: transformsCopy
|
||
} : {};
|
||
|
||
const design = settingsMenu?.getState ? settingsMenu.getState() : {};
|
||
|
||
return {
|
||
name,
|
||
type: "project",
|
||
models,
|
||
viewerState,
|
||
design,
|
||
meta: {
|
||
description: "",
|
||
tags: []
|
||
}
|
||
};
|
||
};
|
||
|
||
const applyDesignState = (design) => {
|
||
if (!design || typeof design !== "object") return;
|
||
if (!settingsMenu?.applyState) {
|
||
pendingDesignState = { ...design };
|
||
return;
|
||
}
|
||
settingsMenu.applyState(design);
|
||
applyToolbarVars(settingsMenu?.getState?.());
|
||
updateToolbarDockMode();
|
||
applyHighlightTheme();
|
||
applyMeasureTheme();
|
||
if (sdkRef) {
|
||
rebuildNavCube(sdkRef);
|
||
}
|
||
};
|
||
|
||
const applyCameraSnapshot = (cameraState) => {
|
||
if (!viewer?.camera || !cameraState || typeof cameraState !== "object") return;
|
||
const { eye, look, up, projection } = cameraState;
|
||
if (Array.isArray(eye) && eye.length === 3) viewer.camera.eye = [...eye];
|
||
if (Array.isArray(look) && look.length === 3) viewer.camera.look = [...look];
|
||
if (Array.isArray(up) && up.length === 3) viewer.camera.up = [...up];
|
||
if (projection) viewer.camera.projection = projection;
|
||
updateProjectionButton();
|
||
viewer.scene?.glRedraw?.();
|
||
};
|
||
|
||
const buildModelViewerSettings = () => ({
|
||
version: 1,
|
||
updatedAt: new Date().toISOString(),
|
||
viewerState: viewer ? {
|
||
camera: viewer.camera ? {
|
||
eye: snapshotVec(viewer.camera.eye),
|
||
look: snapshotVec(viewer.camera.look),
|
||
up: snapshotVec(viewer.camera.up),
|
||
projection: viewer.camera.projection
|
||
} : null,
|
||
transparent: !!toggleTransparent?.checked,
|
||
edges: !!toggleEdges?.checked,
|
||
displayMode: activeDisplayMode,
|
||
customDisplayColor: activeCustomDisplayColor,
|
||
customDisplaySaturation: activeCustomDisplaySaturation,
|
||
lightingMode: activeLightingMode,
|
||
navigationAxis: activeNavigationAxis,
|
||
zoomSpeed: activeZoomSpeed
|
||
} : {},
|
||
design: settingsMenu?.getState ? settingsMenu.getState() : {}
|
||
});
|
||
|
||
const applyModelViewerSettings = (settings, { applyCamera = true, applyDesign = true } = {}) => {
|
||
if (!settings || typeof settings !== "object") return;
|
||
const viewerState = settings.viewerState && typeof settings.viewerState === "object"
|
||
? settings.viewerState
|
||
: {};
|
||
|
||
if (applyDesign && settings.design && typeof settings.design === "object") {
|
||
applyDesignState(settings.design);
|
||
}
|
||
if (!viewer) return;
|
||
|
||
if (typeof viewerState.transparent === "boolean" && toggleTransparent) {
|
||
toggleTransparent.checked = viewerState.transparent;
|
||
viewer.transparent = viewerState.transparent;
|
||
if (viewer.scene?.canvas) {
|
||
viewer.scene.canvas.transparent = viewerState.transparent;
|
||
}
|
||
}
|
||
if (typeof viewerState.edges === "boolean" && toggleEdges) {
|
||
suppressEdgeReload = true;
|
||
toggleEdges.checked = viewerState.edges;
|
||
suppressEdgeReload = false;
|
||
}
|
||
setCustomDisplaySettings({
|
||
color: viewerState.customDisplayColor,
|
||
saturation: viewerState.customDisplaySaturation,
|
||
});
|
||
if (typeof viewerState.displayMode === "string") {
|
||
activeDisplayMode = viewerState.displayMode;
|
||
applyDisplayMode();
|
||
} else if (viewerState.customDisplayColor !== undefined || viewerState.customDisplaySaturation !== undefined) {
|
||
setCustomDisplayControlsState();
|
||
if (normalizeDisplayMode(activeDisplayMode) === "custom") {
|
||
applyDisplayMode();
|
||
}
|
||
}
|
||
if (typeof viewerState.lightingMode === "string") {
|
||
activeLightingMode = viewerState.lightingMode;
|
||
applyLightingMode();
|
||
}
|
||
if (typeof viewerState.navigationAxis === "string") {
|
||
activeNavigationAxis = viewerState.navigationAxis;
|
||
applyNavigationAxis({ resetAuto: false });
|
||
}
|
||
if (viewerState.zoomSpeed !== undefined) {
|
||
setZoomSpeed(viewerState.zoomSpeed);
|
||
}
|
||
if (applyCamera) {
|
||
applyCameraSnapshot(viewerState.camera);
|
||
}
|
||
rearmCameraControl();
|
||
};
|
||
|
||
const fetchModelSettings = async (src) => {
|
||
const normalized = normalizeModelSettingsSrc(src);
|
||
if (!normalized) return null;
|
||
const params = new URLSearchParams({ src: normalized });
|
||
reportViewerEvent("model-settings-request", { src: normalized });
|
||
const data = await fetchJSONWithTimeout(`${MODEL_SETTINGS_API}?${params.toString()}`, {}, 2500);
|
||
reportViewerEvent("model-settings-response", {
|
||
src: data?.sourceSrc || normalized,
|
||
hasViewerSettings: !!data?.viewerSettings,
|
||
});
|
||
return data;
|
||
};
|
||
|
||
const saveCurrentModelSettings = async () => {
|
||
if (isGuestMode()) {
|
||
setStatus("Гостевой просмотр не сохраняет изменения");
|
||
return;
|
||
}
|
||
const entry = getPrimaryModelEntry();
|
||
const settingsSrc = getModelSettingsSrc(entry);
|
||
if (!entry || !settingsSrc) {
|
||
setStatus("Настройки можно сохранить только для модели из Beam-хранилища");
|
||
return;
|
||
}
|
||
|
||
const previousLabel = saveModelSettingsButton?.textContent || "Сохранить настройки";
|
||
if (saveModelSettingsButton) {
|
||
saveModelSettingsButton.disabled = true;
|
||
saveModelSettingsButton.textContent = "Сохраняем...";
|
||
}
|
||
|
||
try {
|
||
const payload = { viewerSettings: buildModelViewerSettings() };
|
||
const params = new URLSearchParams({ src: settingsSrc });
|
||
const saved = await fetchJSON(`${MODEL_SETTINGS_API}?${params.toString()}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
entry.meta = {
|
||
...(entry.meta || {}),
|
||
settingsSrc: saved?.sourceSrc || settingsSrc,
|
||
sourceSrc: saved?.sourceSrc || entry.meta?.sourceSrc || null,
|
||
viewerSettings: saved?.viewerSettings || payload.viewerSettings
|
||
};
|
||
setStatus("Настройки модели сохранены");
|
||
log(`Настройки модели сохранены: ${entry.name || entry.model?.id || settingsSrc}`, false);
|
||
} catch (err) {
|
||
console.error(err);
|
||
showError(err.message || "Не удалось сохранить настройки модели");
|
||
} finally {
|
||
if (saveModelSettingsButton) {
|
||
saveModelSettingsButton.textContent = previousLabel;
|
||
}
|
||
updatePanelsVisibility();
|
||
}
|
||
};
|
||
|
||
const applyViewerState = (state) => {
|
||
if (!viewer || !state || typeof state !== "object") return;
|
||
if (state.camera && viewer.camera) {
|
||
applyCameraSnapshot(state.camera);
|
||
}
|
||
if (typeof state.transparent === "boolean" && toggleTransparent) {
|
||
toggleTransparent.checked = state.transparent;
|
||
toggleTransparent.dispatchEvent(new Event("change"));
|
||
}
|
||
if (typeof state.edges === "boolean" && toggleEdges) {
|
||
suppressEdgeReload = true;
|
||
toggleEdges.checked = state.edges;
|
||
toggleEdges.dispatchEvent(new Event("change"));
|
||
suppressEdgeReload = false;
|
||
}
|
||
if (typeof state.addMode === "boolean" && addMode) {
|
||
addMode.checked = state.addMode;
|
||
}
|
||
setCustomDisplaySettings({
|
||
color: state.customDisplayColor,
|
||
saturation: state.customDisplaySaturation,
|
||
});
|
||
if (typeof state.displayMode === "string") {
|
||
activeDisplayMode = state.displayMode;
|
||
applyDisplayMode();
|
||
} else if (state.customDisplayColor !== undefined || state.customDisplaySaturation !== undefined) {
|
||
setCustomDisplayControlsState();
|
||
if (normalizeDisplayMode(activeDisplayMode) === "custom") {
|
||
applyDisplayMode();
|
||
}
|
||
}
|
||
if (typeof state.lightingMode === "string") {
|
||
activeLightingMode = state.lightingMode;
|
||
applyLightingMode();
|
||
}
|
||
if (typeof state.navigationAxis === "string") {
|
||
activeNavigationAxis = state.navigationAxis;
|
||
applyNavigationAxis({ resetAuto: false });
|
||
}
|
||
if (state.zoomSpeed !== undefined) {
|
||
setZoomSpeed(state.zoomSpeed);
|
||
}
|
||
if (state.transforms && typeof state.transforms === "object") {
|
||
Object.keys(transformStore).forEach((k) => delete transformStore[k]);
|
||
Object.assign(transformStore, safeClone(state.transforms));
|
||
Object.keys(sceneModels).forEach((mid) => applyStoredTransformsToModel(mid));
|
||
}
|
||
if (Array.isArray(state.models)) {
|
||
state.models.forEach((m) => {
|
||
if (!m || !m.id) return;
|
||
const model = sceneModels[m.id];
|
||
if (model && typeof m.visible === "boolean") {
|
||
model.visible = m.visible;
|
||
}
|
||
});
|
||
}
|
||
if (state.selection) {
|
||
const ids = Array.isArray(state.selection.ids) ? state.selection.ids : [];
|
||
const filtered = ids.filter((id) => viewer.scene?.objects?.[id]);
|
||
if (filtered.length) {
|
||
setSelection(filtered, state.selection.modelId || null);
|
||
} else {
|
||
selectionState = null;
|
||
gizmo?.clearTarget?.();
|
||
updateTransformInputs(null);
|
||
updateGlobalInputs(null);
|
||
}
|
||
}
|
||
if (deleteProjectButton) {
|
||
deleteProjectButton.disabled = !currentProjectMeta;
|
||
}
|
||
if (projectNameInput) {
|
||
projectNameInput.value = currentProjectMeta?.name || state.name || "";
|
||
projectNameInput.disabled = !(currentProjectMeta || lastAttemptedProjectId);
|
||
}
|
||
rearmCameraControl();
|
||
};
|
||
|
||
const initViewer = async () => {
|
||
await initializeRuntimeMode();
|
||
setupNodeDCSessionSync();
|
||
setupNodeDCSessionWatcher();
|
||
|
||
const preloadHidden = document.getElementById("preload-hidden");
|
||
if (preloadHidden) {
|
||
preloadHidden.remove();
|
||
}
|
||
document.body.style.opacity = "1";
|
||
|
||
const sdk = await import(SDK_URL);
|
||
sdkRef = sdk;
|
||
const { Viewer, NavCubePlugin, DistanceMeasurementsPlugin, DistanceMeasurementsMouseControl, SectionPlanesPlugin } = sdk;
|
||
mathUtil = sdk.math;
|
||
if (headerEl && homeButton && !homeButton.querySelector(".ue-logo")) {
|
||
const logoEl = createLogo();
|
||
homeButton.appendChild(logoEl);
|
||
homeButton.title = "Открыть NODE.DC Hub";
|
||
homeButton.setAttribute("aria-label", "Открыть NODE.DC Hub");
|
||
if (logoEl.style.width) homeButton.style.width = logoEl.style.width;
|
||
if (logoEl.style.height) homeButton.style.height = logoEl.style.height;
|
||
homeButton.style.border = "none";
|
||
homeButton.style.background = "transparent";
|
||
homeButton.style.padding = "0";
|
||
}
|
||
|
||
// Отключаем cacheBuster для glTF/GLB повсюду, чтобы не ломать blob: URL
|
||
if (sdk.GLTFDefaultDataSource) {
|
||
sdk.GLTFDefaultDataSource.prototype._cacheBusterURL = function (url) {
|
||
return url;
|
||
};
|
||
}
|
||
|
||
// Для blob: используем fetch вместо XHR внутри sdk.utils.loadArraybuffer
|
||
if (sdk.utils && typeof sdk.utils.loadArraybuffer === "function") {
|
||
const originalLoadArraybuffer = sdk.utils.loadArraybuffer;
|
||
sdk.utils.loadArraybuffer = (url, ok, error) => {
|
||
if (typeof url === "string" && url.startsWith("memory:") && memoryStore.has(url)) {
|
||
ok(memoryStore.get(url));
|
||
return;
|
||
}
|
||
if (typeof url === "string" && url.startsWith("blob:")) {
|
||
fetch(url)
|
||
.then((res) => {
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.arrayBuffer();
|
||
})
|
||
.then(ok)
|
||
.catch((err) => error(err.message || err));
|
||
} else {
|
||
originalLoadArraybuffer(url, ok, error);
|
||
}
|
||
};
|
||
}
|
||
if (sdk.LASDefaultDataSource) {
|
||
sdk.LASDefaultDataSource.prototype._cacheBusterURL = function (url) {
|
||
return url;
|
||
};
|
||
}
|
||
|
||
const viewerContextAttr = {};
|
||
await waitForViewerCanvasReady(viewerContextAttr);
|
||
|
||
viewer = new Viewer({
|
||
canvasId: "viewerCanvas",
|
||
transparent: true,
|
||
contextAttr: viewerContextAttr,
|
||
dtxEnabled: true,
|
||
colorTextureEnabled: true,
|
||
});
|
||
configureCameraControl();
|
||
defaultCameraWorldAxis = snapshotVec(viewer.camera?.worldAxis) || [...NAVIGATION_AXIS_PRESETS["y-up"].worldAxis];
|
||
defaultCameraUp = snapshotVec(viewer.camera?.up) || [...NAVIGATION_AXIS_PRESETS["y-up"].up];
|
||
if (viewer.scene?.highlightMaterial) {
|
||
const hexToRGB = (hex) => {
|
||
const h = hex.replace("#", "");
|
||
const bigint = parseInt(h.length === 3 ? h.split("").map((c) => c + c).join("") : h, 16);
|
||
const r = (bigint >> 16) & 255;
|
||
const g = (bigint >> 8) & 255;
|
||
const b = bigint & 255;
|
||
return [r / 255, g / 255, b / 255];
|
||
};
|
||
const hl = getComputedStyle(document.documentElement).getPropertyValue("--highlight-color").trim() || "#b833ff";
|
||
const color = hexToRGB(hl);
|
||
viewer.scene.highlightMaterial.color = color;
|
||
if (viewer.scene.highlightMaterial.edgeColor) {
|
||
viewer.scene.highlightMaterial.edgeColor = color;
|
||
}
|
||
if (viewer.scene.highlightMaterial.fillColor) {
|
||
viewer.scene.highlightMaterial.fillColor = [...color, 0.5];
|
||
}
|
||
}
|
||
|
||
if (SectionPlanesPlugin && sectionOverviewCanvas) {
|
||
sectionPlanesPlugin = new SectionPlanesPlugin(viewer, {
|
||
overviewCanvasId: "sectionPlanesOverview",
|
||
overviewVisible: false,
|
||
});
|
||
sectionPlanesPlugin.setOverviewVisible(false);
|
||
applySectionPlaneTheme();
|
||
setSectionModeActive(false);
|
||
} else {
|
||
console.warn("SectionPlanesPlugin is not available in the loaded SDK build");
|
||
if (sectionButton) {
|
||
sectionButton.classList.add("disabled");
|
||
sectionButton.disabled = true;
|
||
sectionButton.title = "Разрез недоступен";
|
||
}
|
||
}
|
||
|
||
if (DistanceMeasurementsPlugin && DistanceMeasurementsMouseControl) {
|
||
measurePlugin = new DistanceMeasurementsPlugin(viewer, {
|
||
defaultAxisVisible: false,
|
||
defaultLabelsOnWires: true,
|
||
});
|
||
measureControl = new DistanceMeasurementsMouseControl(measurePlugin, {});
|
||
measureControl.snapping = true;
|
||
measurePlugin.on?.("measurementCreated", applyMeasureTheme);
|
||
if (measureModal) {
|
||
measurementModal = createMeasurementModal({
|
||
panel: measureModal,
|
||
backdrop: measureModalBackdrop,
|
||
onClose: () => {
|
||
activeMeasurementId = null;
|
||
},
|
||
onDelete: (id) => {
|
||
if (!measurePlugin || !id) return;
|
||
measurePlugin.destroyMeasurement(id);
|
||
closeMeasurementModal();
|
||
},
|
||
onToggleVisibility: (id, nextVisible) => {
|
||
const measurement = measurePlugin?.measurements?.[id];
|
||
if (!measurement) return;
|
||
measurement.visible = nextVisible;
|
||
measurementModal?.setVisibility?.(measurement.visible);
|
||
},
|
||
});
|
||
measurePlugin.on?.("contextMenu", (evt) => {
|
||
const measurement = evt?.measurement || evt?.distanceMeasurement;
|
||
if (!measurement) return;
|
||
evt.event?.preventDefault?.();
|
||
activeMeasurementId = measurement.id;
|
||
measurementModal?.show?.({
|
||
measurementId: measurement.id,
|
||
visible: measurement.visible !== false,
|
||
});
|
||
});
|
||
}
|
||
applyMeasureTheme();
|
||
setMeasureActive(false);
|
||
} else {
|
||
console.warn("DistanceMeasurementsPlugin is not available in the loaded SDK build");
|
||
if (measureButton) {
|
||
measureButton.classList.add("disabled");
|
||
measureButton.title = "Линейка недоступна";
|
||
}
|
||
}
|
||
|
||
viewer.transparent = toggleTransparent.checked;
|
||
if (viewer.scene?.canvas) {
|
||
viewer.scene.canvas.transparent = toggleTransparent.checked;
|
||
}
|
||
applyLightingMode();
|
||
updateProjectionButton();
|
||
|
||
if (mainCanvas && !mainCanvas.hasAttribute("tabindex")) {
|
||
mainCanvas.setAttribute("tabindex", "0");
|
||
}
|
||
if (mainCanvas) {
|
||
mainCanvas.addEventListener("pointerdown", () => {
|
||
focusCanvasAndControls();
|
||
}, true);
|
||
mainCanvas.addEventListener("pointerenter", scheduleCameraRearm);
|
||
}
|
||
|
||
rebuildNavCube(sdk);
|
||
resetTreeView(sdk);
|
||
applyToolbarVars(settingsMenu?.getState?.());
|
||
updateToolbarDockMode();
|
||
applyHighlightTheme();
|
||
updatePanelsVisibility();
|
||
ensureCameraControlActive();
|
||
gizmo = new TransformGizmo({
|
||
viewer,
|
||
sdk,
|
||
treeView,
|
||
transformStore,
|
||
sceneModels,
|
||
onTargetChange: (data) => {
|
||
if (!data) {
|
||
updateTransformInputs(null);
|
||
return;
|
||
}
|
||
updateTransformInputs(data.transform);
|
||
},
|
||
onTransformChange: (payload) => {
|
||
const { modelId, objectId, transform } = payload;
|
||
persistTransform(modelId, objectId || "__model__", transform);
|
||
if (selectionState) {
|
||
applyGroupTransform({
|
||
position: transform.position,
|
||
rotation: transform.rotation,
|
||
scale: transform.scale,
|
||
});
|
||
} else {
|
||
// одиночный объект через гизмо
|
||
const model = sceneModels[modelId];
|
||
if (model) {
|
||
model.position = transform.position ? [...transform.position] : model.position;
|
||
model.rotation = transform.rotation ? [...transform.rotation] : model.rotation;
|
||
model.scale = transform.scale ? [...transform.scale] : model.scale;
|
||
updateTransformInputs(transform);
|
||
viewer.scene.setAABBDirty?.();
|
||
viewer.scene.glRedraw?.();
|
||
}
|
||
}
|
||
},
|
||
});
|
||
|
||
// Пик по сцене: ЛКМ по объекту -> выделение (ПКМ контекст меню отключено)
|
||
const canvasEl = viewer.scene?.canvas?.canvas;
|
||
if (canvasEl) {
|
||
let downPos = null;
|
||
const clearCommentLongPress = () => {
|
||
if (commentLongPressTimer) {
|
||
window.clearTimeout(commentLongPressTimer);
|
||
commentLongPressTimer = null;
|
||
}
|
||
commentLongPressStart = null;
|
||
};
|
||
canvasEl.addEventListener("contextmenu", (event) => {
|
||
const opened = openCommentContextMenuFromEvent(event);
|
||
if (!opened) event.preventDefault();
|
||
});
|
||
canvasEl.addEventListener("pointerdown", restorePointerNavigation, { capture: true });
|
||
canvasEl.addEventListener("wheel", guardEmptyWheelNavigation, { capture: true, passive: true });
|
||
canvasEl.addEventListener("pointerdown", (event) => {
|
||
if (measureActive) {
|
||
downPos = null;
|
||
clearCommentLongPress();
|
||
return;
|
||
}
|
||
downPos = { x: event.clientX, y: event.clientY };
|
||
if (!isGuestMode() && activeModels.length && (event.pointerType === "touch" || event.pointerType === "pen")) {
|
||
clearCommentLongPress();
|
||
commentLongPressStart = {
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
};
|
||
commentLongPressTimer = window.setTimeout(() => {
|
||
if (!commentLongPressStart) return;
|
||
const syntheticEvent = {
|
||
clientX: commentLongPressStart.x,
|
||
clientY: commentLongPressStart.y,
|
||
preventDefault: () => {},
|
||
stopPropagation: () => {},
|
||
};
|
||
if (openCommentContextMenuFromEvent(syntheticEvent)) {
|
||
suppressNextCanvasClick = true;
|
||
}
|
||
clearCommentLongPress();
|
||
}, 620);
|
||
}
|
||
});
|
||
canvasEl.addEventListener("pointermove", (event) => {
|
||
if (!commentLongPressStart) return;
|
||
const dx = Math.abs(event.clientX - commentLongPressStart.x);
|
||
const dy = Math.abs(event.clientY - commentLongPressStart.y);
|
||
if (dx > 10 || dy > 10) clearCommentLongPress();
|
||
});
|
||
canvasEl.addEventListener("pointerup", clearCommentLongPress);
|
||
canvasEl.addEventListener("pointercancel", clearCommentLongPress);
|
||
canvasEl.addEventListener("pointerleave", clearCommentLongPress);
|
||
canvasEl.addEventListener("wheel", () => {
|
||
hideCommentContextMenu();
|
||
clearCommentLongPress();
|
||
});
|
||
canvasEl.addEventListener("click", (event) => {
|
||
if (measureActive) {
|
||
return;
|
||
}
|
||
if (suppressNextCanvasClick) {
|
||
suppressNextCanvasClick = false;
|
||
downPos = null;
|
||
return;
|
||
}
|
||
if (downPos) {
|
||
const dx = Math.abs(event.clientX - downPos.x);
|
||
const dy = Math.abs(event.clientY - downPos.y);
|
||
// если это был драг камеры, не считаем кликом по объекту
|
||
if (dx > 3 || dy > 3) {
|
||
downPos = null;
|
||
return;
|
||
}
|
||
}
|
||
const rect = canvasEl.getBoundingClientRect();
|
||
const x = event.clientX - rect.left;
|
||
const y = event.clientY - rect.top;
|
||
const hit = viewer.scene.pick({
|
||
canvasPos: [x, y],
|
||
pickSurface: true,
|
||
});
|
||
if (hit && hit.entity) {
|
||
const objectId = hit.entity.id || hit.entity.objectId;
|
||
const modelId = hit.entity.model?.id || hit.entity.modelId;
|
||
if (objectId && modelId) {
|
||
selectObject({ modelId, objectId });
|
||
}
|
||
} else {
|
||
// клик в пустоту — снимаем выделение
|
||
viewer.scene.setObjectsHighlighted(viewer.scene.highlightedObjectIds || [], false);
|
||
gizmo?.clearTarget();
|
||
updateTransformInputs(null);
|
||
treeView?.unShowNode?.();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Делегирование клика по инспектору (дереву)
|
||
if (treeContainer) {
|
||
const handleTreeHit = (li, { fly = false } = {}) => {
|
||
let didFly = false;
|
||
if (!li) return didFly;
|
||
// если это основной TreeViewPlugin узел
|
||
if (treeView && treeView._nodeNodes && treeView._nodeNodes[li.id]) {
|
||
const node = treeView._nodeNodes[li.id];
|
||
highlightTreeNode(node, { fromTree: true });
|
||
if (fly) {
|
||
const ids = collectObjectIds(node);
|
||
const modelId = getModelIdFromNode(node) || findModelIdForObjectId(ids[0]);
|
||
flyToObjectIds(ids, modelId);
|
||
didFly = true;
|
||
}
|
||
return didFly;
|
||
}
|
||
// fallback дерево (ручное)
|
||
const span = li.querySelector(":scope > span");
|
||
const text = (span?.textContent || "").trim();
|
||
if (!text) return didFly;
|
||
// корень модели
|
||
if (li.parentElement === treeContainer.querySelector("ul")) {
|
||
if (sceneModels[text]) {
|
||
const ids = Object.keys(sceneModels[text].objects || {});
|
||
setSelection(ids, text);
|
||
if (fly) {
|
||
flyToObjectIds(ids, text);
|
||
didFly = true;
|
||
}
|
||
}
|
||
return didFly;
|
||
}
|
||
// дочерний узел fallback-дерева
|
||
let modelId = null;
|
||
let parentLi = li;
|
||
while (parentLi) {
|
||
const titleEl = parentLi.querySelector(":scope > span");
|
||
const t = titleEl ? titleEl.textContent.trim() : "";
|
||
if (t && sceneModels[t]) {
|
||
modelId = t;
|
||
break;
|
||
}
|
||
parentLi = parentLi.parentElement?.closest("li");
|
||
}
|
||
if (modelId) {
|
||
setSelection([text], modelId);
|
||
if (fly) {
|
||
flyToObjectIds([text], modelId);
|
||
didFly = true;
|
||
}
|
||
}
|
||
return didFly;
|
||
};
|
||
|
||
treeContainer.addEventListener("click", (e) => {
|
||
const target = e.target;
|
||
if (!target) return;
|
||
if (target === treeContainer) {
|
||
console.info("[DC Tree] click empty area");
|
||
viewer.scene.setObjectsHighlighted(viewer.scene.highlightedObjectIds || [], false);
|
||
gizmo?.clearTarget();
|
||
treeView?.unShowNode?.();
|
||
updateTransformInputs(null);
|
||
updateGlobalInputs(null);
|
||
return;
|
||
}
|
||
if (target.tagName === "INPUT") return; // игнор чекбоксов
|
||
const li = target.closest("li");
|
||
handleTreeHit(li, { fly: false });
|
||
});
|
||
|
||
treeContainer.addEventListener("dblclick", (e) => {
|
||
const target = e.target;
|
||
if (!target) return;
|
||
if (target.tagName === "INPUT") return;
|
||
const li = target.closest("li");
|
||
console.info("[DC Tree] dblclick handler", { liId: li?.id });
|
||
const didFly = handleTreeHit(li, { fly: true });
|
||
if (!didFly) {
|
||
const fallbackIds = selectionState?.ids && selectionState.ids.length
|
||
? selectionState.ids
|
||
: (activeModels[0]?.model ? Object.keys(activeModels[0].model.objects || {}) : []);
|
||
const fallbackModelId = selectionState?.modelId || activeModels[0]?.model?.id || null;
|
||
if (fallbackIds?.length) {
|
||
flyToObjectIds(fallbackIds, fallbackModelId);
|
||
}
|
||
}
|
||
});
|
||
|
||
treeContainer.addEventListener("contextmenu", (e) => {
|
||
const target = e.target;
|
||
if (!target) return;
|
||
const li = target.closest("li");
|
||
if (!li) return;
|
||
|
||
let node = null;
|
||
if (treeView && treeView._nodeNodes && treeView._nodeNodes[li.id]) {
|
||
node = treeView._nodeNodes[li.id];
|
||
}
|
||
|
||
let modelId = null;
|
||
if (node) {
|
||
modelId = node.modelId || getModelIdFromNode(node) || node.id || null;
|
||
if (!modelId && node.objectId) {
|
||
modelId = findModelIdForObjectId(node.objectId);
|
||
}
|
||
}
|
||
|
||
// fallback дерево или не распознали modelId
|
||
if (!modelId) {
|
||
let span = li.querySelector(":scope > span");
|
||
let text = (span?.textContent || "").trim();
|
||
let cur = li;
|
||
// поднимаемся до корня списка
|
||
while ((!text || !sceneModels[text]) && cur?.parentElement) {
|
||
cur = cur.parentElement.closest("li");
|
||
span = cur?.querySelector(":scope > span");
|
||
text = (span?.textContent || "").trim();
|
||
}
|
||
if (text && sceneModels[text]) {
|
||
modelId = text;
|
||
}
|
||
}
|
||
|
||
if (!modelId) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const label = getModelLabel(modelId);
|
||
objectContextModal?.show?.({ modelId, label });
|
||
}, true); // capture: перехватываем до внутренних обработчиков дерева
|
||
}
|
||
|
||
const loadModel = (options) => {
|
||
const debugOptions = options || {};
|
||
try {
|
||
const { type, url, name = "", replace = true, id: forcedId, meta, edges } = options;
|
||
const modelSettings = meta?.viewerSettings;
|
||
const normalizedType = normalizeType(type, typeof url === "string" ? url : "");
|
||
const inferredType = normalizedType || normalizeType(guessTypeFromName(typeof url === "string" ? url : ""));
|
||
if (!inferredType || !acceptByType[inferredType]) {
|
||
reportViewerEvent("load-model-unsupported", { type, url, name, normalizedType, inferredType }, "error");
|
||
showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl");
|
||
return null;
|
||
}
|
||
|
||
if (replace) {
|
||
destroyAllModels({ preserveMeta: isLoadingSavedProject });
|
||
}
|
||
|
||
if (modelSettings) {
|
||
applyModelViewerSettings(modelSettings, { applyCamera: false });
|
||
}
|
||
|
||
setStatus(`Загрузка: ${name || inferredType}...`);
|
||
setLoading(true);
|
||
const isBlob = typeof url === "string" && url.startsWith("blob:");
|
||
const isMemory = typeof url === "string" && url.startsWith("memory:");
|
||
log(`loader: ${inferredType}, blob=${isBlob}, memory=${isMemory}, url=${url}`, false);
|
||
reportViewerEvent("load-model-start", {
|
||
inferredType,
|
||
isBlob,
|
||
isMemory,
|
||
name,
|
||
replace,
|
||
url,
|
||
});
|
||
const xktDataSource = inferredType === "xkt"
|
||
? (isBlob || isMemory ? createMemoryDataSource() : new sdk.XKTDefaultDataSource({ cacheBuster: !isBlob }))
|
||
: null;
|
||
const gltfDataSource = inferredType === "gltf"
|
||
? (isBlob || isMemory ? createMemoryDataSource() : new sdk.GLTFDefaultDataSource({ cacheBuster: false }))
|
||
: null;
|
||
const lasDataSource = inferredType === "las"
|
||
? (isBlob || isMemory ? createMemoryDataSource() : null)
|
||
: null;
|
||
const stlDataSource = inferredType === "stl"
|
||
? (isBlob || isMemory ? createMemoryDataSource() : null)
|
||
: null;
|
||
const currentLoader = loaderFor(sdk, inferredType, {
|
||
cacheBuster: isBlob ? false : true,
|
||
dataSource: xktDataSource || gltfDataSource || lasDataSource || stlDataSource || undefined,
|
||
});
|
||
|
||
const id = forcedId || (replace ? "model" : `model-${++modelCounter}`);
|
||
let srcToLoad = url;
|
||
if (inferredType === "obj" && typeof url === "string" && url.startsWith("memory:") && memoryStore.has(url)) {
|
||
const blobUrl = URL.createObjectURL(new Blob([memoryStore.get(url)], { type: "text/plain" }));
|
||
blobUrls.push(blobUrl);
|
||
srcToLoad = blobUrl;
|
||
}
|
||
const loadOpts = {
|
||
id,
|
||
src: srcToLoad,
|
||
edges: edges ?? (inferredType === "gltf" || inferredType === "las" ? false : toggleEdges.checked),
|
||
};
|
||
if (inferredType === "gltf") loadOpts.autoMetaModel = true;
|
||
if (inferredType === "bim" || inferredType === "las") loadOpts.rotation = [-90, 0, 0];
|
||
if (inferredType === "stl") loadOpts.smoothNormals = true;
|
||
|
||
const currentModel = currentLoader.load(loadOpts);
|
||
sceneModels[id] = currentModel;
|
||
modelTypes[id] = inferredType;
|
||
|
||
currentModel.on("loaded", () => {
|
||
reportViewerEvent("model-loaded", {
|
||
id,
|
||
inferredType,
|
||
name,
|
||
objectCount: Object.keys(viewer.scene.objects || {}).length,
|
||
src: srcToLoad,
|
||
});
|
||
setTimeout(() => applyStoredTransformsToModel(id), 0);
|
||
setTimeout(() => applyStoredTransformsToModel(id), 60);
|
||
const hasModelCamera = !!modelSettings?.viewerState?.camera;
|
||
if (!isLoadingSavedProject && !hasModelCamera) {
|
||
viewer.cameraFlight.flyTo({ aabb: viewer.scene.getAABB({}) });
|
||
}
|
||
if (modelSettings) {
|
||
setTimeout(() => applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: false }), 0);
|
||
}
|
||
setStatus(`Загружено: ${name || inferredType}`);
|
||
dropZone.classList.add("hidden");
|
||
setLoading(false);
|
||
if (inferredType === "las") {
|
||
snapshotPointCloudColors(id);
|
||
}
|
||
applyDisplayModeToModel(id);
|
||
updateNavCubeVisibility();
|
||
updatePanelsVisibility();
|
||
rearmCameraControl();
|
||
// добавляем в дерево только если есть метамодель
|
||
setTimeout(() => {
|
||
const metaPresent = hasMetaModel(id);
|
||
if (metaPresent && !treeModelIds.has(id) && treeView?.addModel) {
|
||
try {
|
||
treeView.addModel(id, { rootName: name || id });
|
||
treeModelIds.add(id);
|
||
} catch (err) {
|
||
console.warn("tree addModel", err);
|
||
}
|
||
}
|
||
const hasNodes = treeContainer?.querySelector("li");
|
||
if (!metaPresent || !hasNodes) {
|
||
renderFallbackTree(id);
|
||
}
|
||
}, 250);
|
||
});
|
||
|
||
currentModel.on("error", (err) => {
|
||
reportViewerEvent("model-error", {
|
||
id,
|
||
inferredType,
|
||
name,
|
||
error: err?.message || String(err),
|
||
src: srcToLoad,
|
||
}, "error");
|
||
showError(`Ошибка загрузки: ${err}`);
|
||
setLoading(false);
|
||
updateNavCubeVisibility();
|
||
updatePanelsVisibility();
|
||
});
|
||
activeModels.push({
|
||
model: currentModel,
|
||
loader: currentLoader,
|
||
type: inferredType,
|
||
src: typeof url === "string" ? url : srcToLoad,
|
||
name,
|
||
meta
|
||
});
|
||
updateNavCubeVisibility();
|
||
updatePanelsVisibility();
|
||
refreshModelVersionHistory({ silent: true }).catch(() => {});
|
||
|
||
// если есть сохранённые трансформы для модели — применим
|
||
applyStoredTransformsToModel(id);
|
||
return currentModel;
|
||
} catch (e) {
|
||
console.error(e);
|
||
reportViewerEvent("load-model-exception", {
|
||
type: debugOptions.type,
|
||
url: debugOptions.url,
|
||
name: debugOptions.name,
|
||
error: e?.message || String(e),
|
||
}, "error");
|
||
showError(e.message || "Ошибка загрузки");
|
||
setLoading(false);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const loadModelAsync = (options) => new Promise((resolve, reject) => {
|
||
const model = loadModel(options);
|
||
if (!model || !model.on) {
|
||
reject(new Error("Не удалось инициировать загрузку модели"));
|
||
return;
|
||
}
|
||
model.on("loaded", () => resolve(model));
|
||
model.on("error", (err) => reject(new Error(err?.message || err)));
|
||
});
|
||
|
||
const loadStartupModelFromQuery = async () => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const sharedViewer = startupSharePayload?.viewer || null;
|
||
const src = sharedViewer?.src || params.get("url") || params.get("src");
|
||
reportViewerEvent("startup-query", {
|
||
search: window.location.search,
|
||
mode: viewerMode,
|
||
hasShare: !!sharedViewer,
|
||
hasUrl: params.has("url"),
|
||
hasSrc: params.has("src"),
|
||
src,
|
||
typeParam: sharedViewer?.type || params.get("type"),
|
||
nameParam: sharedViewer?.name || params.get("name"),
|
||
});
|
||
if (!src) {
|
||
reportViewerEvent("startup-no-src", { search: window.location.search }, "error");
|
||
clearShareStartupPending();
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
const name = sharedViewer?.name || params.get("name") || src.split("/").pop() || "model";
|
||
const type = normalizeType(sharedViewer?.type || params.get("type"), src) || normalizeType(guessTypeFromName(name));
|
||
if (!type) {
|
||
reportViewerEvent("startup-type-error", { src, name, typeParam: params.get("type") }, "error");
|
||
showError("Не удалось определить формат модели из URL");
|
||
clearShareStartupPending();
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
dropZone?.classList.add("hidden");
|
||
setStatus(`Подготовка: ${name}...`);
|
||
setLoading(true);
|
||
clearShareStartupPending();
|
||
|
||
let settingsSrc = sharedViewer?.settingsSrc || params.get("settingsSrc") || params.get("sourceSrc") || params.get("originalSrc") || src;
|
||
const applyQueryViewerOverrides = () => {
|
||
if (params.has("displayMode")) {
|
||
activeDisplayMode = params.get("displayMode") || "source";
|
||
setDisplayModeControlState();
|
||
}
|
||
setCustomDisplaySettings({
|
||
color: params.has("customDisplayColor") ? params.get("customDisplayColor") : undefined,
|
||
saturation: params.has("customDisplaySaturation") ? params.get("customDisplaySaturation") : undefined,
|
||
});
|
||
if ((params.has("customDisplayColor") || params.has("customDisplaySaturation")) && normalizeDisplayMode(activeDisplayMode) === "custom") {
|
||
applyDisplayMode();
|
||
}
|
||
if (params.has("lightingMode") || params.has("light")) {
|
||
activeLightingMode = params.get("lightingMode") || params.get("light") || "technical";
|
||
applyLightingMode();
|
||
}
|
||
if (params.has("navigationAxis") || params.has("axis")) {
|
||
activeNavigationAxis = params.get("navigationAxis") || params.get("axis") || "auto";
|
||
applyNavigationAxis({ resetAuto: false });
|
||
}
|
||
if (params.has("zoomSpeed")) {
|
||
setZoomSpeed(params.get("zoomSpeed"));
|
||
}
|
||
};
|
||
applyQueryViewerOverrides();
|
||
|
||
const startupMeta = {
|
||
label: name,
|
||
source: sharedViewer ? "share" : "query",
|
||
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src),
|
||
artifactSrc: normalizeModelSettingsSrc(src),
|
||
assetId: sharedViewer?.assetId || null,
|
||
projectId: sharedViewer?.projectId || null,
|
||
versionId: sharedViewer?.versionId || null,
|
||
versions: Array.isArray(sharedViewer?.versions) ? sharedViewer.versions : [],
|
||
shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(),
|
||
viewerSettings: null,
|
||
};
|
||
|
||
const applyFetchedStartupSettings = (settingsResponse) => {
|
||
const modelSettings = settingsResponse?.viewerSettings || null;
|
||
settingsSrc = settingsResponse?.sourceSrc || settingsSrc;
|
||
reportViewerEvent("startup-settings-loaded", {
|
||
settingsSrc,
|
||
hasViewerSettings: !!modelSettings,
|
||
});
|
||
if (!modelSettings) return;
|
||
const normalizedSettingsSrc = normalizeModelSettingsSrc(settingsSrc) || startupMeta.settingsSrc;
|
||
startupMeta.settingsSrc = normalizedSettingsSrc;
|
||
startupMeta.viewerSettings = modelSettings;
|
||
const entry = activeModels.find((item) => item?.meta === startupMeta) || getPrimaryModelEntry();
|
||
if (entry) {
|
||
entry.meta = {
|
||
...(entry.meta || {}),
|
||
settingsSrc: normalizedSettingsSrc,
|
||
sourceSrc: normalizedSettingsSrc,
|
||
artifactSrc: startupMeta.artifactSrc,
|
||
viewerSettings: modelSettings,
|
||
};
|
||
}
|
||
applyModelViewerSettings(modelSettings, { applyCamera: true, applyDesign: true });
|
||
applyQueryViewerOverrides();
|
||
};
|
||
|
||
fetchModelSettings(settingsSrc)
|
||
.then(applyFetchedStartupSettings)
|
||
.catch((err) => {
|
||
console.warn("model settings unavailable", err);
|
||
reportViewerEvent("startup-settings-unavailable", {
|
||
settingsSrc,
|
||
error: err?.message || String(err),
|
||
});
|
||
});
|
||
|
||
if (params.has("edges")) {
|
||
startupMeta.viewerSettings = {
|
||
viewerState: {
|
||
edges: params.get("edges") !== "false"
|
||
}
|
||
};
|
||
}
|
||
|
||
reportViewerEvent("startup-load-model", {
|
||
src,
|
||
name,
|
||
type,
|
||
settingsSrc,
|
||
hasViewerSettings: false,
|
||
});
|
||
loadModel({
|
||
type,
|
||
url: src,
|
||
name,
|
||
replace: params.get("replace") !== "false",
|
||
edges: params.has("edges") ? params.get("edges") !== "false" : undefined,
|
||
meta: startupMeta,
|
||
});
|
||
};
|
||
|
||
const startupModelLoadPromise = loadStartupModelFromQuery().catch((err) => {
|
||
console.warn("startup model load failed", err);
|
||
reportViewerEvent("startup-load-failed", {
|
||
message: err?.message || String(err),
|
||
stack: err?.stack,
|
||
}, "error");
|
||
showError(err.message || "Не удалось загрузить модель из URL");
|
||
});
|
||
|
||
const loadProjectSnapshot = async (project) => {
|
||
if (!project) {
|
||
throw new Error("Нет данных проекта");
|
||
}
|
||
isLoadingSavedProject = true;
|
||
suppressEdgeReload = true;
|
||
try {
|
||
destroyAllModels({ preserveMeta: false });
|
||
currentProjectMeta = { id: project.id, name: project.name, type: project.type };
|
||
if (projectNameInput) {
|
||
projectNameInput.disabled = false;
|
||
projectNameInput.value = project.name || "";
|
||
}
|
||
Object.keys(transformStore).forEach((k) => delete transformStore[k]);
|
||
const savedTransforms = project.viewerState?.transforms || project.viewerState?.transformStore;
|
||
if (savedTransforms) {
|
||
Object.assign(transformStore, safeClone(savedTransforms));
|
||
}
|
||
const models = Array.isArray(project.models) ? project.models : [];
|
||
if (!models.length) {
|
||
applyViewerState(project.viewerState);
|
||
applyDesignState(project.design);
|
||
updatePanelsVisibility();
|
||
return;
|
||
}
|
||
for (let i = 0; i < models.length; i++) {
|
||
const modelCfg = models[i] || {};
|
||
const modelId = modelCfg.id || (i === 0 ? "model" : `model-${i + 1}`);
|
||
const modelType = normalizeType(modelCfg.type, modelCfg.src) || normalizeType(guessTypeFromName(modelCfg.src));
|
||
if (!modelType) {
|
||
throw new Error(`Не удалось определить тип модели ${modelId}`);
|
||
}
|
||
await loadModelAsync({
|
||
type: modelType,
|
||
url: modelCfg.src,
|
||
name: modelCfg.meta?.label || modelCfg.name || modelId,
|
||
replace: i === 0,
|
||
id: modelId,
|
||
meta: modelCfg.meta
|
||
});
|
||
}
|
||
applyViewerState(project.viewerState);
|
||
applyDesignState(project.design);
|
||
// Повторно накатываем трансформы чуть позже, если загрузчики внутри что-то сбросили
|
||
setTimeout(() => {
|
||
Object.keys(sceneModels).forEach((mid) => applyStoredTransformsToModel(mid));
|
||
}, 50);
|
||
setStatus(`Проект "${project.name}" загружен`);
|
||
templatesMenu?.hide();
|
||
} finally {
|
||
isLoadingSavedProject = false;
|
||
suppressEdgeReload = false;
|
||
updatePanelsVisibility();
|
||
rearmCameraControl();
|
||
}
|
||
};
|
||
|
||
const loadProjectFromServer = async (projectId) => {
|
||
try {
|
||
lastAttemptedProjectId = projectId;
|
||
const meta = findProjectMetaById(projectId) || { id: projectId, name: "", type: "project" };
|
||
currentProjectMeta = meta;
|
||
if (deleteProjectButton) deleteProjectButton.disabled = false;
|
||
if (projectNameInput) {
|
||
projectNameInput.disabled = false;
|
||
projectNameInput.value = meta.name || "";
|
||
}
|
||
updatePanelsVisibility();
|
||
setStatus("Загрузка проекта...");
|
||
setLoading(true);
|
||
const project = await fetchProjectById(projectId);
|
||
currentProjectMeta = { id: project.id, name: project.name, type: project.type };
|
||
if (deleteProjectButton) deleteProjectButton.disabled = false;
|
||
await loadProjectSnapshot(project);
|
||
} catch (err) {
|
||
console.error(err);
|
||
showError(err.message || "Не удалось загрузить проект");
|
||
} finally {
|
||
setLoading(false);
|
||
updatePanelsVisibility();
|
||
}
|
||
};
|
||
|
||
const closeSaveModal = () => {
|
||
saveProjectBackdrop?.classList.add("hidden");
|
||
saveProjectModal?.classList.add("hidden");
|
||
if (saveProjectError) saveProjectError.textContent = "";
|
||
if (saveProjectConfirm) {
|
||
saveProjectConfirm.disabled = false;
|
||
saveProjectConfirm.textContent = "Сохранить";
|
||
}
|
||
if (saveProjectDuplicate) {
|
||
saveProjectDuplicate.disabled = false;
|
||
saveProjectDuplicate.textContent = "Сохранить как новый";
|
||
}
|
||
if (saveProjectHint) {
|
||
saveProjectHint.textContent = "Введите имя, чтобы сохранить проект в хранилище";
|
||
}
|
||
};
|
||
|
||
const openSaveModal = () => {
|
||
if (isGuestMode()) return;
|
||
if (!activeModels.length && !currentProjectMeta) return;
|
||
const defaultName = currentProjectMeta?.name || "Новый проект";
|
||
if (saveProjectNameInput) {
|
||
saveProjectNameInput.value = defaultName;
|
||
saveProjectNameInput.focus();
|
||
saveProjectNameInput.select();
|
||
}
|
||
saveProjectError && (saveProjectError.textContent = "");
|
||
saveProjectBackdrop?.classList.remove("hidden");
|
||
saveProjectModal?.classList.remove("hidden");
|
||
if (saveProjectConfirm) {
|
||
saveProjectConfirm.disabled = !(saveProjectNameInput?.value.trim());
|
||
}
|
||
if (saveProjectDuplicate) {
|
||
saveProjectDuplicate.disabled = !(saveProjectNameInput?.value.trim());
|
||
}
|
||
};
|
||
|
||
const closeDeleteModal = () => {
|
||
deleteProjectBackdrop?.classList.add("hidden");
|
||
deleteProjectModal?.classList.add("hidden");
|
||
if (deleteProjectError) deleteProjectError.textContent = "";
|
||
if (deleteProjectConfirm) {
|
||
deleteProjectConfirm.disabled = false;
|
||
deleteProjectConfirm.textContent = "Удалить";
|
||
}
|
||
};
|
||
|
||
const openDeleteModal = () => {
|
||
if (isGuestMode()) return;
|
||
if (!currentProjectMeta && lastAttemptedProjectId) {
|
||
const meta = findProjectMetaById(lastAttemptedProjectId) || { id: lastAttemptedProjectId, name: "", type: "project" };
|
||
currentProjectMeta = meta;
|
||
}
|
||
if (!currentProjectMeta) return;
|
||
deleteProjectBackdrop?.classList.remove("hidden");
|
||
deleteProjectModal?.classList.remove("hidden");
|
||
if (deleteProjectError) deleteProjectError.textContent = "";
|
||
};
|
||
|
||
const submitDeleteProject = async () => {
|
||
if (isGuestMode()) return;
|
||
const targetId = currentProjectMeta?.id || lastAttemptedProjectId;
|
||
if (!targetId) return;
|
||
if (deleteProjectError) deleteProjectError.textContent = "";
|
||
if (deleteProjectConfirm) {
|
||
deleteProjectConfirm.disabled = true;
|
||
deleteProjectConfirm.textContent = "Удаление...";
|
||
}
|
||
try {
|
||
await fetchJSON(`${API_BASE}/${targetId}`, { method: "DELETE" });
|
||
await loadProjectsList().catch(() => {});
|
||
destroyAllModels();
|
||
currentProjectMeta = null;
|
||
lastAttemptedProjectId = null;
|
||
setStatus("Проект удалён");
|
||
if (projectNameInput) {
|
||
projectNameInput.value = "";
|
||
projectNameInput.disabled = true;
|
||
}
|
||
closeDeleteModal();
|
||
updatePanelsVisibility();
|
||
templatesMenu?.hide();
|
||
if (deleteProjectButton) deleteProjectButton.disabled = true;
|
||
} catch (err) {
|
||
console.error(err);
|
||
if (deleteProjectError) deleteProjectError.textContent = err.message || "Не удалось удалить проект";
|
||
} finally {
|
||
if (deleteProjectConfirm) {
|
||
deleteProjectConfirm.disabled = false;
|
||
deleteProjectConfirm.textContent = "Удалить";
|
||
}
|
||
}
|
||
};
|
||
|
||
const submitSaveProject = async (mode = "update") => {
|
||
if (isGuestMode()) return;
|
||
if (!saveProjectNameInput) return;
|
||
const trimmedName = saveProjectNameInput.value.trim();
|
||
if (!trimmedName) {
|
||
if (saveProjectError) saveProjectError.textContent = "Имя проекта обязательно";
|
||
if (saveProjectConfirm) saveProjectConfirm.disabled = true;
|
||
return;
|
||
}
|
||
if (saveProjectError) saveProjectError.textContent = "";
|
||
const targetId = currentProjectMeta?.id || lastAttemptedProjectId || null;
|
||
const targetType = currentProjectMeta?.type || "project";
|
||
const effectiveMode = (mode === "new") ? "new" : (targetId ? "update" : "new");
|
||
const isUpdate = effectiveMode === "update" && !!targetId;
|
||
if (saveProjectConfirm) {
|
||
saveProjectConfirm.disabled = true;
|
||
saveProjectConfirm.textContent = isUpdate ? "Сохранение..." : "Сохранение...";
|
||
}
|
||
if (saveProjectDuplicate) {
|
||
saveProjectDuplicate.disabled = true;
|
||
saveProjectDuplicate.textContent = mode === "new" ? "Сохранение..." : "Сохранить как новый";
|
||
}
|
||
if (saveProjectHint) {
|
||
saveProjectHint.textContent = "Сохраняем проект...";
|
||
}
|
||
try {
|
||
const payload = await buildProjectSnapshot(trimmedName);
|
||
payload.type = targetType || payload.type || "project";
|
||
if (isUpdate && targetId) {
|
||
payload.id = targetId;
|
||
} else {
|
||
delete payload.id;
|
||
}
|
||
const saved = await fetchJSON(isUpdate ? `${API_BASE}/${targetId}` : API_BASE, {
|
||
method: isUpdate ? "PUT" : "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
currentProjectMeta = { id: saved.id, name: saved.name, type: saved.type };
|
||
lastAttemptedProjectId = currentProjectMeta.id;
|
||
await loadProjectsList().catch(() => {});
|
||
closeSaveModal();
|
||
setStatus(`Проект "${saved.name}" ${isUpdate ? "обновлён" : "сохранён"}`);
|
||
if (deleteProjectButton) deleteProjectButton.disabled = !currentProjectMeta;
|
||
if (projectNameInput) {
|
||
projectNameInput.disabled = false;
|
||
projectNameInput.value = saved.name || "";
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
if (saveProjectError) saveProjectError.textContent = err.message || "Не удалось сохранить проект";
|
||
} finally {
|
||
if (saveProjectConfirm) {
|
||
saveProjectConfirm.disabled = false;
|
||
saveProjectConfirm.textContent = "Сохранить";
|
||
}
|
||
if (saveProjectDuplicate) {
|
||
saveProjectDuplicate.disabled = false;
|
||
saveProjectDuplicate.textContent = "Сохранить как новый";
|
||
}
|
||
if (saveProjectHint) {
|
||
saveProjectHint.textContent = "Введите имя, чтобы сохранить проект в хранилище";
|
||
}
|
||
}
|
||
};
|
||
|
||
// Меню шаблонов / проектов (после объявления loadModel)
|
||
templatesMenu = createTemplatesMenu({
|
||
panel: templatesPanel,
|
||
fetchProjects: () => loadProjectsList(),
|
||
openProject: (projectId) => loadProjectFromServer(projectId),
|
||
onShow: () => {
|
||
bringFloatingWindowToFront("templates");
|
||
setToolbarButtonActive(templatesButton, true);
|
||
setToolbarButtonActive(settingsButton, false);
|
||
},
|
||
onHide: () => {
|
||
setToolbarButtonActive(templatesButton, false);
|
||
}
|
||
});
|
||
settingsMenu = createSettingsMenu({
|
||
panel: settingsPanel,
|
||
controls: {
|
||
toggleEdges,
|
||
toggleTransparent,
|
||
addMode,
|
||
},
|
||
onChange: ({ key }) => {
|
||
if (key === "cubeText" || key === "cubeEdges" || (key && key.startsWith("navCube"))) {
|
||
rebuildNavCube(sdk);
|
||
}
|
||
if (key === "highlight") {
|
||
applyHighlightTheme();
|
||
}
|
||
if (key && key.startsWith("measure")) {
|
||
applyMeasureTheme();
|
||
}
|
||
if (key && key.startsWith("section")) {
|
||
applySectionPlaneTheme();
|
||
}
|
||
if (key && key.startsWith("commentTarget")) {
|
||
renderCommentTargets();
|
||
}
|
||
if (key && key.startsWith("toolbar")) {
|
||
applyToolbarVars(settingsMenu?.getState?.());
|
||
updateToolbarDockMode();
|
||
}
|
||
focusCanvasAndControls();
|
||
},
|
||
onShow: () => {
|
||
bringFloatingWindowToFront("settings");
|
||
setToolbarButtonActive(settingsButton, true);
|
||
setToolbarButtonActive(templatesButton, false);
|
||
closeTemplatesPanel();
|
||
},
|
||
onHide: () => {
|
||
setToolbarButtonActive(settingsButton, false);
|
||
}
|
||
});
|
||
if (pendingDesignState) {
|
||
const design = pendingDesignState;
|
||
pendingDesignState = null;
|
||
applyDesignState(design);
|
||
}
|
||
// ensure persisted settings are applied to cube/highlight on first load
|
||
applyToolbarVars(settingsMenu?.getState?.());
|
||
updateToolbarDockMode();
|
||
rebuildNavCube(sdk);
|
||
applyHighlightTheme();
|
||
applyMeasureTheme();
|
||
applySectionPlaneTheme();
|
||
if (modelVersionControl && modelVersionSelectHost) {
|
||
modelVersionSelect = createGlassSelect({
|
||
host: modelVersionSelectHost,
|
||
value: "loading",
|
||
options: [{ value: "loading", label: "Версии не найдены" }],
|
||
ariaLabel: "Версия модели",
|
||
onChange: (value) => {
|
||
const versions = modelVersionHistory?.versions || getPrimaryModelEntry()?.meta?.versions || [];
|
||
const version = versions.find((item) => getVersionKey(item) === value);
|
||
if (version) openModelVersion(version);
|
||
},
|
||
});
|
||
updateModelVersionControl();
|
||
}
|
||
if (displayModeControl && displayModeSelectHost) {
|
||
displayModeSelect = createGlassSelect({
|
||
host: displayModeSelectHost,
|
||
value: activeDisplayMode,
|
||
options: DISPLAY_MODE_OPTIONS,
|
||
ariaLabel: "Режим отображения",
|
||
onChange: (value) => {
|
||
activeDisplayMode = value;
|
||
applyDisplayMode();
|
||
focusCanvasAndControls();
|
||
},
|
||
});
|
||
setDisplayModeControlState();
|
||
}
|
||
if (customDisplayColorControlHost) {
|
||
customDisplayColorControl = createColorControl({
|
||
value: activeCustomDisplayColor,
|
||
ariaLabel: "Цвет кастомного режима",
|
||
valueLabel: "HEX цвет кастомного режима",
|
||
onChange: (value) => {
|
||
setCustomDisplaySettings({ color: value });
|
||
if (normalizeDisplayMode(activeDisplayMode) === "custom") {
|
||
applyDisplayMode();
|
||
}
|
||
},
|
||
});
|
||
customDisplayColorControlHost.replaceChildren(customDisplayColorControl);
|
||
}
|
||
if (customDisplaySaturationControl) {
|
||
customDisplaySaturationSlider = createSliderControl({
|
||
label: "Насыщенность",
|
||
value: activeCustomDisplaySaturation,
|
||
min: 0,
|
||
max: 200,
|
||
step: 1,
|
||
className: "inspector-slider",
|
||
showValue: true,
|
||
formatValue: (value) => `${Math.round(value)}%`,
|
||
onChange: (value) => {
|
||
setCustomDisplaySettings({ saturation: value });
|
||
if (normalizeDisplayMode(activeDisplayMode) === "custom") {
|
||
applyDisplayMode();
|
||
}
|
||
},
|
||
});
|
||
customDisplaySaturationControl.replaceChildren(customDisplaySaturationSlider);
|
||
}
|
||
setCustomDisplayControlsState();
|
||
if (lightingModeControl && lightingModeSelectHost) {
|
||
lightingModeSelect = createGlassSelect({
|
||
host: lightingModeSelectHost,
|
||
value: activeLightingMode,
|
||
options: LIGHTING_MODE_OPTIONS,
|
||
ariaLabel: "Свет",
|
||
onChange: (value) => {
|
||
activeLightingMode = value;
|
||
applyLightingMode({ announce: true });
|
||
focusCanvasAndControls();
|
||
},
|
||
});
|
||
setLightingModeControlState();
|
||
}
|
||
if (navigationAxisControl && navigationAxisSelectHost) {
|
||
navigationAxisSelect = createGlassSelect({
|
||
host: navigationAxisSelectHost,
|
||
value: activeNavigationAxis,
|
||
options: NAVIGATION_AXIS_OPTIONS,
|
||
ariaLabel: "Ось навигации",
|
||
onChange: (value) => {
|
||
activeNavigationAxis = value;
|
||
applyNavigationAxis({ resetAuto: true, announce: true });
|
||
focusCanvasAndControls();
|
||
},
|
||
});
|
||
setNavigationAxisControlState();
|
||
}
|
||
if (zoomSpeedControl) {
|
||
zoomSpeedSlider = createSliderControl({
|
||
label: "Скорость зума",
|
||
value: activeZoomSpeed,
|
||
min: 5,
|
||
max: 100,
|
||
step: 1,
|
||
className: "inspector-slider",
|
||
showValue: true,
|
||
formatValue: (value) => `${Math.round(value)}%`,
|
||
onChange: (value) => {
|
||
setZoomSpeed(value);
|
||
},
|
||
});
|
||
zoomSpeedControl.replaceChildren(zoomSpeedSlider);
|
||
}
|
||
inspectorCollapseButton?.addEventListener("click", () => setInspectorCollapsed(true));
|
||
inspectorRestoreButton?.addEventListener("click", () => {
|
||
setInspectorCollapsed(false);
|
||
bringFloatingWindowToFront("inspector");
|
||
});
|
||
modelVersionHistoryButton?.addEventListener("click", () => openVersionHistoryModal());
|
||
modelVersionUploadButton?.addEventListener("click", () => {
|
||
if (isGuestMode() || modelVersionUploading) return;
|
||
modelVersionFileInput?.click();
|
||
});
|
||
modelVersionFileInput?.addEventListener("change", async (event) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (file) await uploadModelVersion(file);
|
||
});
|
||
registerFloatingWindowElement("inspector", sidePanelEl);
|
||
registerFloatingWindowElement("comments", commentsPanel);
|
||
registerFloatingWindowElement("settings", settingsPanel);
|
||
registerFloatingWindowElement("templates", templatesPanel);
|
||
registerFloatingWindowElement("commentModal", commentModal);
|
||
setupDraggableFixedWindow(commentsPanel, commentsPanelHeader);
|
||
setupDraggableFixedWindow(commentModal, commentModalHeader);
|
||
setupResizableFixedWindow(commentModal, { minWidth: 660, minHeight: 420 });
|
||
commentsCollapseButton?.addEventListener("click", () => setCommentsCollapsed(true));
|
||
commentsRestoreButton?.addEventListener("click", () => {
|
||
commentsModeActive = true;
|
||
commentsCollapsed = false;
|
||
bringFloatingWindowToFront("comments");
|
||
updateCommentsVisibility();
|
||
});
|
||
commentContextMenu?.addEventListener("click", (event) => {
|
||
const button = event.target?.closest?.("[data-comment-menu-action]");
|
||
if (!button) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (button.dataset.commentMenuAction === "create") {
|
||
hideCommentContextMenu();
|
||
openCommentModalForTarget();
|
||
}
|
||
});
|
||
document.addEventListener("click", (event) => {
|
||
if (!commentContextMenu || commentContextMenu.classList.contains("hidden")) return;
|
||
if (commentContextMenu.contains(event.target)) return;
|
||
hideCommentContextMenu();
|
||
});
|
||
commentModalClose?.addEventListener("click", closeCommentModal);
|
||
bindFooterButtonAction(commentCancelButton, closeCommentModal);
|
||
commentModalExpandButton?.addEventListener("click", () => setCommentModalExpanded(!commentModalExpanded));
|
||
bindFooterButtonAction(commentApplyButton, () => applyCommentModal({ closeOnSave: false }));
|
||
bindFooterButtonAction(commentSaveButton, () => applyCommentModal({ closeOnSave: true }));
|
||
commentApplyInlineButton?.addEventListener("click", () => applyCommentModal({ closeOnSave: false }));
|
||
commentModalBackdrop?.addEventListener("click", (event) => {
|
||
if (event.target === commentModalBackdrop) closeCommentModal();
|
||
});
|
||
commentAttachButton?.addEventListener("click", () => commentAttachmentInput?.click());
|
||
commentReplyAttachButton?.addEventListener("click", () => commentReplyAttachmentInput?.click());
|
||
commentAddSubitemButton?.addEventListener("click", (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
toggleCommentSubitemMenu();
|
||
});
|
||
commentSubitemMenu?.addEventListener("click", (event) => {
|
||
const button = event.target?.closest?.("[data-comment-subitem-kind]");
|
||
if (!button) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
addCommentSubitem(button.dataset.commentSubitemKind);
|
||
hideCommentSubitemMenu();
|
||
});
|
||
document.addEventListener("click", (event) => {
|
||
if (!commentSubitemMenu || commentSubitemMenu.classList.contains("hidden")) return;
|
||
if (commentSubitemMenu.contains(event.target) || commentAddSubitemButton?.contains(event.target)) return;
|
||
hideCommentSubitemMenu();
|
||
});
|
||
commentAttachmentInput?.addEventListener("change", async (event) => {
|
||
const files = await readAttachmentFiles(event.target.files);
|
||
commentDraftAttachments = [...commentDraftAttachments, ...files].slice(0, 8);
|
||
event.target.value = "";
|
||
renderModalAttachmentInputs();
|
||
});
|
||
commentReplyAttachmentInput?.addEventListener("change", async (event) => {
|
||
const files = await readAttachmentFiles(event.target.files);
|
||
commentReplyAttachments = [...commentReplyAttachments, ...files].slice(0, 8);
|
||
event.target.value = "";
|
||
renderModalAttachmentInputs();
|
||
});
|
||
if (commentDropzone) {
|
||
commentDropzone.addEventListener("click", (event) => {
|
||
if (event.target?.closest?.("button,input,.comment-attachment-thumb")) return;
|
||
commentAttachmentInput?.click();
|
||
});
|
||
["dragenter", "dragover"].forEach((type) => {
|
||
commentDropzone.addEventListener(type, (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
commentDropzone.classList.add("drag-over");
|
||
});
|
||
});
|
||
["dragleave", "drop"].forEach((type) => {
|
||
commentDropzone.addEventListener(type, (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
commentDropzone.classList.remove("drag-over");
|
||
});
|
||
});
|
||
commentDropzone.addEventListener("drop", async (event) => {
|
||
const files = await readAttachmentFiles(event.dataTransfer?.files);
|
||
commentDraftAttachments = [...commentDraftAttachments, ...files].slice(0, 8);
|
||
renderModalAttachmentInputs();
|
||
});
|
||
}
|
||
if (!isGuestMode()) {
|
||
loadProjectsList().catch(() => {
|
||
templatesMenu?.setData({ templates: templateFallbacks, projects: [], error: "API недоступно" });
|
||
});
|
||
}
|
||
|
||
const handleFile = async (file) => {
|
||
if (isGuestMode()) {
|
||
setStatus("Гостевой просмотр не принимает загрузку файлов");
|
||
return;
|
||
}
|
||
const detected = guessTypeFromName(file.name);
|
||
if (!detected) {
|
||
showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl");
|
||
return;
|
||
}
|
||
log(`Файл выбран: ${file.name} (${detected})`, false);
|
||
setStatus(`Загружаем: ${file.name}...`);
|
||
setLoading(true);
|
||
try {
|
||
const buf = await file.arrayBuffer();
|
||
const bytes = cloneToUint8(buf) || new Uint8Array(buf.byteLength || 0);
|
||
const uploaded = await uploadBuffer(bytes, file.name);
|
||
const uploadIdentity = {
|
||
projectId: uploaded.projectId || null,
|
||
assetId: uploaded.assetId || null,
|
||
};
|
||
loadModel({
|
||
type: detected,
|
||
url: uploaded.src,
|
||
name: file.name,
|
||
replace: !addMode.checked,
|
||
meta: {
|
||
label: file.name,
|
||
source: "upload",
|
||
settingsSrc: uploaded.src,
|
||
sourceSrc: uploaded.src,
|
||
artifactSrc: uploaded.src,
|
||
assetId: uploadIdentity.assetId,
|
||
projectId: uploadIdentity.projectId,
|
||
version: uploaded.version || null,
|
||
versionId: uploaded.versionId || null,
|
||
versions: [],
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.error(err);
|
||
showError(err.message || "Не удалось загрузить модель");
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
if (chooseFileBtn) {
|
||
chooseFileBtn.addEventListener("click", () => fileInput?.click());
|
||
}
|
||
|
||
if (fileInput) {
|
||
fileInput.addEventListener("change", (event) => {
|
||
const file = event.target.files?.[0];
|
||
if (file) handleFile(file);
|
||
fileInput.value = "";
|
||
});
|
||
}
|
||
|
||
if (viewerWrapper) {
|
||
["dragenter", "dragover"].forEach((evt) => {
|
||
viewerWrapper.addEventListener(evt, (e) => {
|
||
if (isGuestMode()) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dropZone.classList.add("active");
|
||
});
|
||
});
|
||
|
||
["dragleave", "drop"].forEach((evt) => {
|
||
viewerWrapper.addEventListener(evt, (e) => {
|
||
if (isGuestMode()) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
dropZone.classList.remove("active");
|
||
});
|
||
});
|
||
|
||
viewerWrapper.addEventListener("drop", (e) => {
|
||
if (isGuestMode()) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const file = e.dataTransfer?.files?.[0];
|
||
if (file) {
|
||
handleFile(file);
|
||
}
|
||
});
|
||
}
|
||
|
||
fitViewBtn.addEventListener("click", () => {
|
||
viewer.cameraFlight.flyTo({ aabb: viewer.scene.getAABB({}) });
|
||
});
|
||
|
||
toggleEdges.addEventListener("change", async () => {
|
||
if (suppressEdgeReload) return;
|
||
if (!activeModels.length) return;
|
||
setStatus("Перезагрузка с новыми рёбрами...");
|
||
try {
|
||
const snapshot = await buildProjectSnapshot(currentProjectMeta?.name || "Текущий проект");
|
||
snapshot.id = currentProjectMeta?.id || "local_reload";
|
||
snapshot.type = currentProjectMeta?.type || "project";
|
||
await loadProjectSnapshot(snapshot);
|
||
} catch (err) {
|
||
console.error(err);
|
||
showError(err.message || "Не удалось перезагрузить сцену");
|
||
}
|
||
});
|
||
|
||
toggleTransparent.addEventListener("change", () => {
|
||
viewer.transparent = toggleTransparent.checked;
|
||
if (viewer.scene?.canvas) {
|
||
viewer.scene.canvas.transparent = toggleTransparent.checked;
|
||
}
|
||
});
|
||
|
||
homeButton.addEventListener("click", () => {
|
||
const sharePromoteUrl = getShareTokenFromPath() && isGuestMode() ? getShareTopLevelPromotionUrl() : "";
|
||
window.location.assign(sharePromoteUrl || getLauncherUrl());
|
||
});
|
||
|
||
if (saveProjectButton) {
|
||
saveProjectButton.addEventListener("click", openSaveModal);
|
||
}
|
||
if (saveModelSettingsButton) {
|
||
saveModelSettingsButton.addEventListener("click", saveCurrentModelSettings);
|
||
}
|
||
saveProjectCancel?.addEventListener("click", closeSaveModal);
|
||
saveProjectClose?.addEventListener("click", closeSaveModal);
|
||
saveProjectConfirm?.addEventListener("click", submitSaveProject);
|
||
saveProjectDuplicate?.addEventListener("click", () => submitSaveProject("new"));
|
||
saveProjectBackdrop?.addEventListener("click", (e) => {
|
||
if (e.target === saveProjectBackdrop) {
|
||
closeSaveModal();
|
||
}
|
||
});
|
||
saveProjectNameInput?.addEventListener("input", () => {
|
||
saveProjectError && (saveProjectError.textContent = "");
|
||
if (saveProjectConfirm) {
|
||
saveProjectConfirm.disabled = !saveProjectNameInput.value.trim();
|
||
}
|
||
if (saveProjectDuplicate) {
|
||
saveProjectDuplicate.disabled = !saveProjectNameInput.value.trim();
|
||
}
|
||
});
|
||
saveProjectNameInput?.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
submitSaveProject();
|
||
}
|
||
});
|
||
projectNameInput?.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
submitProjectRename();
|
||
}
|
||
});
|
||
projectNameInput?.addEventListener("blur", () => {
|
||
submitProjectRename();
|
||
});
|
||
if (deleteProjectButton) {
|
||
deleteProjectButton.addEventListener("click", openDeleteModal);
|
||
}
|
||
deleteProjectCancel?.addEventListener("click", closeDeleteModal);
|
||
deleteProjectClose?.addEventListener("click", closeDeleteModal);
|
||
deleteProjectConfirm?.addEventListener("click", submitDeleteProject);
|
||
deleteProjectBackdrop?.addEventListener("click", (e) => {
|
||
if (e.target === deleteProjectBackdrop) {
|
||
closeDeleteModal();
|
||
}
|
||
});
|
||
shareModalCancel?.addEventListener("click", closeShareModal);
|
||
shareModalClose?.addEventListener("click", closeShareModal);
|
||
shareCopyButton?.addEventListener("click", copyShareLink);
|
||
shareModalBackdrop?.addEventListener("click", (e) => {
|
||
if (e.target === shareModalBackdrop) {
|
||
closeShareModal();
|
||
}
|
||
});
|
||
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key !== "Escape") return;
|
||
if (commentSubitemMenu && !commentSubitemMenu.classList.contains("hidden")) {
|
||
hideCommentSubitemMenu();
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (commentModal && !commentModal.classList.contains("hidden")) {
|
||
closeCommentModal();
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (commentContextMenu && !commentContextMenu.classList.contains("hidden")) {
|
||
hideCommentContextMenu();
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (e.target && ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return;
|
||
if (shareModal && !shareModal.classList.contains("hidden")) {
|
||
closeShareModal();
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (deleteProjectModal && !deleteProjectModal.classList.contains("hidden")) {
|
||
closeDeleteModal();
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (saveProjectModal && !saveProjectModal.classList.contains("hidden")) {
|
||
closeSaveModal();
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
if (measureActive && measureControl?.currentMeasurement) {
|
||
measureControl.reset();
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
});
|
||
|
||
// Горячие клавиши W/E/R для переключения режимов гизмы
|
||
document.addEventListener("keydown", (e) => {
|
||
if (!gizmo) return;
|
||
if (e.target && ["INPUT", "TEXTAREA"].includes(e.target.tagName)) return;
|
||
if (e.key === "w" || e.key === "W") gizmo.setMode("translate");
|
||
if (e.key === "e" || e.key === "E") gizmo.setMode("rotate");
|
||
if (e.key === "r" || e.key === "R") gizmo.setMode("scale");
|
||
});
|
||
|
||
modeButtons.forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const mode = btn.dataset.mode;
|
||
gizmo?.setMode(mode);
|
||
});
|
||
});
|
||
|
||
const bindInput = (key, transformKey) => {
|
||
const input = transformInputs[key];
|
||
if (!input) return;
|
||
input.addEventListener("focus", () => {
|
||
const fallbackModel =
|
||
selectionState?.modelId ||
|
||
gizmo?.target?.modelId ||
|
||
lastSelection?.modelId;
|
||
const fallbackObject =
|
||
selectionState?.ids?.[0] ||
|
||
gizmo?.target?.objectId ||
|
||
lastSelection?.ids?.[0] ||
|
||
"__model__";
|
||
if (fallbackModel) {
|
||
activeTransformTarget = { modelId: fallbackModel, objectId: fallbackObject || "__model__" };
|
||
}
|
||
});
|
||
const handler = (e) => {
|
||
const modelId =
|
||
selectionState?.modelId ||
|
||
gizmo?.target?.modelId ||
|
||
activeTransformTarget?.modelId ||
|
||
lastSelection?.modelId ||
|
||
null;
|
||
const objectId =
|
||
selectionState?.ids?.[0] ||
|
||
gizmo?.target?.objectId ||
|
||
activeTransformTarget?.objectId ||
|
||
lastSelection?.ids?.[0] ||
|
||
"__model__";
|
||
if (!modelId) return;
|
||
|
||
const model =
|
||
(sceneModels[modelId] || viewer?.scene?.models?.[modelId]) ||
|
||
null;
|
||
if (!model) return;
|
||
const entity =
|
||
objectId && objectId !== "__model__"
|
||
? resolveEntity(objectId, modelId) || model
|
||
: model;
|
||
const resolvedEntity = entity || model;
|
||
const modelTransforms = (modelId && transformStore[modelId]) || {};
|
||
|
||
const isInputEvent = e?.type === "input";
|
||
const beforeVal = input.value || "";
|
||
// Разрешаем свободное редактирование: пока в поле пусто или просто "-", не применяем и не перетираем значение
|
||
let rawVal = beforeVal.trim();
|
||
if (rawVal === "" || rawVal === "-") {
|
||
return;
|
||
}
|
||
const value = parseInputNumber(rawVal);
|
||
if (!Number.isFinite(value)) {
|
||
return;
|
||
}
|
||
|
||
// приоритет: значения из инпутов -> сохранённые -> текущие в сцене -> дефолты
|
||
const uiPos = [
|
||
parseInputNumber(transformInputs.posX?.value),
|
||
parseInputNumber(transformInputs.posY?.value),
|
||
parseInputNumber(transformInputs.posZ?.value),
|
||
];
|
||
const uiRot = [
|
||
parseInputNumber(transformInputs.rotX?.value),
|
||
parseInputNumber(transformInputs.rotY?.value),
|
||
parseInputNumber(transformInputs.rotZ?.value),
|
||
];
|
||
const uiScale = [
|
||
parseInputNumber(transformInputs.scaleX?.value),
|
||
parseInputNumber(transformInputs.scaleY?.value),
|
||
parseInputNumber(transformInputs.scaleZ?.value),
|
||
];
|
||
const stored = modelTransforms[objectId] || modelTransforms["__model__"] || {};
|
||
const basePos = stored.position
|
||
? [...stored.position]
|
||
: resolvedEntity?.position
|
||
? [...resolvedEntity.position]
|
||
: [0, 0, 0];
|
||
const baseRot = stored.rotation
|
||
? [...stored.rotation]
|
||
: resolvedEntity?.rotation
|
||
? [...resolvedEntity.rotation]
|
||
: [0, 0, 0];
|
||
const baseScale = stored.scale
|
||
? [...stored.scale]
|
||
: resolvedEntity?.scale
|
||
? [...resolvedEntity.scale]
|
||
: [1, 1, 1];
|
||
|
||
const next = {
|
||
position: [
|
||
Number.isFinite(uiPos[0]) ? uiPos[0] : basePos[0] || 0,
|
||
Number.isFinite(uiPos[1]) ? uiPos[1] : basePos[1] || 0,
|
||
Number.isFinite(uiPos[2]) ? uiPos[2] : basePos[2] || 0,
|
||
],
|
||
rotation: [
|
||
Number.isFinite(uiRot[0]) ? uiRot[0] : baseRot[0] || 0,
|
||
Number.isFinite(uiRot[1]) ? uiRot[1] : baseRot[1] || 0,
|
||
Number.isFinite(uiRot[2]) ? uiRot[2] : baseRot[2] || 0,
|
||
],
|
||
scale: [
|
||
Number.isFinite(uiScale[0]) ? uiScale[0] : baseScale[0] || 1,
|
||
Number.isFinite(uiScale[1]) ? uiScale[1] : baseScale[1] || 1,
|
||
Number.isFinite(uiScale[2]) ? uiScale[2] : baseScale[2] || 1,
|
||
],
|
||
};
|
||
if (transformKey === "position") {
|
||
const axis = key.slice(-1).toLowerCase();
|
||
const idx = axis === "x" ? 0 : axis === "y" ? 1 : 2;
|
||
next.position[idx] = value;
|
||
} else if (transformKey === "rotation") {
|
||
const axis = key.slice(-1).toLowerCase();
|
||
const idx = axis === "x" ? 0 : axis === "y" ? 1 : 2;
|
||
next.rotation[idx] = value;
|
||
} else if (transformKey === "scale") {
|
||
const axis = key.slice(-1).toLowerCase();
|
||
const idx = axis === "x" ? 0 : axis === "y" ? 1 : 2;
|
||
next.scale[idx] = value || 1;
|
||
}
|
||
if (selectionState) {
|
||
applyGroupTransform(next);
|
||
} else {
|
||
resolvedEntity.position = [...next.position];
|
||
resolvedEntity.rotation = [...next.rotation];
|
||
resolvedEntity.scale = [...next.scale];
|
||
const key =
|
||
resolvedEntity === model || !objectId ? "__model__" : objectId;
|
||
persistTransform(modelId, key, {
|
||
position: [...resolvedEntity.position],
|
||
rotation: [...resolvedEntity.rotation],
|
||
scale: [...resolvedEntity.scale],
|
||
});
|
||
activeTransformTarget = { modelId, objectId: key };
|
||
if (!isInputEvent) {
|
||
updateTransformInputs(next);
|
||
}
|
||
viewer.scene.setAABBDirty?.();
|
||
viewer.scene.glRedraw?.();
|
||
}
|
||
const refreshTarget = selectionState
|
||
? { modelId: modelId, objectId: objectId || "__model__" }
|
||
: (activeTransformTarget || gizmo?.target);
|
||
if (refreshTarget?.modelId) {
|
||
gizmo?.setTarget(refreshTarget);
|
||
}
|
||
};
|
||
input.addEventListener("change", handler);
|
||
input.addEventListener("blur", handler);
|
||
input.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Enter") {
|
||
handler(ev);
|
||
input.blur();
|
||
}
|
||
});
|
||
};
|
||
|
||
bindInput("posX", "position");
|
||
bindInput("posY", "position");
|
||
bindInput("posZ", "position");
|
||
bindInput("rotX", "rotation");
|
||
bindInput("rotY", "rotation");
|
||
bindInput("rotZ", "rotation");
|
||
bindInput("scaleX", "scale");
|
||
bindInput("scaleY", "scale");
|
||
bindInput("scaleZ", "scale");
|
||
|
||
// Переустановим pivot камеры на центр текущего выделения при двойном клике в инспекторе, если не переданы ids
|
||
if (treeContainer) {
|
||
treeContainer.addEventListener("dblclick", () => {
|
||
if (selectionState?.pivotCenter && viewer?.cameraControl) {
|
||
viewer.cameraControl.pivotPos = selectionState.pivotCenter;
|
||
}
|
||
});
|
||
}
|
||
|
||
if (objectModal) {
|
||
objectContextModal = createObjectModal({
|
||
panel: objectModal,
|
||
backdrop: objectModalBackdrop,
|
||
onDelete: (target) => {
|
||
if (!target?.modelId) return;
|
||
deleteModelById(target.modelId);
|
||
objectContextModal?.hide?.();
|
||
},
|
||
onClose: () => {}
|
||
});
|
||
}
|
||
|
||
void startupModelLoadPromise;
|
||
};
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
initViewer().catch((err) => {
|
||
reportViewerEvent("init-viewer-error", {
|
||
message: err?.message || String(err),
|
||
stack: err?.stack,
|
||
}, "error");
|
||
showError(err?.message || "Не удалось инициализировать viewer");
|
||
});
|
||
});
|
||
|
||
// нижняя тулбар
|
||
if (bottomToolbar) {
|
||
setupToolbarDock();
|
||
bottomToolbar.addEventListener("click", (e) => {
|
||
const btn = e.target.closest(".tb-btn");
|
||
if (!btn) return;
|
||
if (btn.disabled) return;
|
||
const action = btn.dataset.action;
|
||
if (isGuestMode() && !isGuestToolbarAction(action)) return;
|
||
if (action === "settings") {
|
||
if (btn.classList.contains("active")) {
|
||
closeSettingsPanel();
|
||
} else {
|
||
openSettingsPanel();
|
||
}
|
||
} else if (action === "templates") {
|
||
if (btn.classList.contains("active")) {
|
||
closeTemplatesPanel();
|
||
} else {
|
||
openTemplatesPanel();
|
||
}
|
||
} else if (action === "measure") {
|
||
if (!measurePlugin || !measureControl) return;
|
||
setMeasureActive(!measureActive);
|
||
} else if (action === "section") {
|
||
toggleSectionPlanes();
|
||
} else if (action === "comments") {
|
||
toggleCommentsMode();
|
||
} else if (action === "projection") {
|
||
toggleProjection();
|
||
} else if (action === "share") {
|
||
openShareModal();
|
||
} else if (action === "add") {
|
||
fileInput.click();
|
||
}
|
||
});
|
||
}
|
||
|
||
sectionToolbar?.addEventListener("click", (e) => {
|
||
const btn = e.target.closest("[data-section-action]");
|
||
if (!btn) return;
|
||
const action = btn.dataset.sectionAction;
|
||
if (action === "camera" || action === "x" || action === "y" || action === "z") {
|
||
createOrUpdateSectionPlane(action);
|
||
} else if (action === "flip") {
|
||
flipSectionPlanes();
|
||
} else if (action === "clear") {
|
||
clearSectionPlanes();
|
||
setStatus("Разрез выключен");
|
||
}
|
||
});
|
||
|
||
function updateTransformInputs(transform) {
|
||
const empty = {
|
||
position: ["", "", ""],
|
||
rotation: ["", "", ""],
|
||
scale: ["", "", ""],
|
||
};
|
||
const t = transform || empty;
|
||
const pos = t.position || ["", "", ""];
|
||
const rot = t.rotation || ["", "", ""];
|
||
const scale = t.scale || ["", "", ""];
|
||
transformInputs.posX.value = pos[0];
|
||
transformInputs.posY.value = pos[1];
|
||
transformInputs.posZ.value = pos[2];
|
||
transformInputs.rotX.value = rot[0];
|
||
transformInputs.rotY.value = rot[1];
|
||
transformInputs.rotZ.value = rot[2];
|
||
transformInputs.scaleX.value = scale[0];
|
||
transformInputs.scaleY.value = scale[1];
|
||
transformInputs.scaleZ.value = scale[2];
|
||
}
|