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