/* DFG 3D-Viewer Copyright (C) 2025 - Daniel Dworak This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details at https://www.gnu.org/licenses/. */ //Supported file formats: OBJ, DAE, FBX, PLY, IFC, STL, XYZ, JSON, 3DS, PCD, glTF const SOURCE = (typeof __BUILD_SOURCE__ !== 'undefined') ? __BUILD_SOURCE__ : ""; const IS_PROD = (typeof __IS_PROD__ !== 'undefined') ? __IS_PROD__ === true : false; const isE2E = (typeof __IS_PROD__ !== 'undefined') ? window.__E2E__ === true : false; const envSubDir = (typeof __ENV_SUBDIR__ !== 'undefined') ? __ENV_SUBDIR__ : "main"; window.viewer = { ready: false, modelLoaded: false, webglReady: false, camera: null, scene: null, renderer: null, controls: null }; import { core, setCore } from './core.js'; import { normalizeColor, } from "./utils.js"; import { initClippingPlanes, reportViewerError, showToast, toastHelper, changeBackground } from './viewer-utils.js'; import { attachEmbedConfigurator } from "./ui/embed-configurator.js"; import { buildThumbnailGallery } from "./ui/thumbnail-gallery.js"; import { attachLocalizationTheme } from "./ui/localization-theme.js"; import { attachLoadingStatus } from "./ui/loading-status.js"; import { attachMaterialsEditor } from "./editor/materials-editor.js"; import { buildEditorMetadata, saveEditorMetadata as persistEditorMetadata } from "./editor/metadata-persistence.js"; import { attachAnnotations } from "./editor/annotations.js"; import { attachMeasurement } from "./editor/measurement.js"; import { attachPicking } from "./editor/picking.js"; import { captureAndUploadThumbnail } from "./editor/thumbnail-capture.js"; import { loadModel, outlineClipping, getModuleAssetBasePath, syncSceneEnvironment } from "./loaders.js"; import { createIIIFDropdown, createManifestUI, createAIM3IFDropdown } from "./metadata.js"; import { UltraLoader } from "./ultra-loader.js"; import { StatusPoller } from "./status-poller.js"; //three.js core import THREE from "./init.js"; //three.js components import TWEEN from "three/examples/jsm/libs/tween.module.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { TransformControls } from "three/examples/jsm/controls/TransformControls.js"; import { FontLoader } from "three/examples/jsm/loaders/FontLoader.js"; //custom libraries import Stats from "stats.js"; import { GUI } from "./js/external_libs/lil-gui.esm.min.js"; import { objectsConfig, setObjectsConfig } from "./object-settings.js"; import { loadIIIFManifest, getAnnotations } from "./IIIF/iiif-api.js"; import { loadAIM3IFManifest, applyManifestConfig } from "./manifesto/manifesto-api.js"; import { attachEditorToolbar, createEditorToolbar, getEditorToolbarIcon, getEditorToolbarHost, syncEditorToolbarSecondaryTrayWidth, toggleToolbarExpanded as toggleEditorToolbarExpanded, updateClippingPlanesSubmenuState, updateEditorToolbarLabels as syncEditorToolbarLabels, updateEditorToolbarState as syncEditorToolbarState, updateHierarchySubmenuState, updateLightsSubmenuState, updateStatisticsSubmenuState, } from "./editor-toolbar.js"; import { VIEWER_I18N } from "./i18n.js"; import { t } from "./i18n-utils.js"; import { loadDroppedArchive } from "./extract-helper.js"; import { loadDroppedModel } from "./sandbox.js"; import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'; export const Viewer = { CONFIG: null, PRESENTATION_MODE: false, SANDBOX_MODE: false, SUPPORTED_EXTENSIONS: ['glb', 'gltf', 'obj', 'dae', 'fbx', 'ply', 'ifc', 'stl', 'xyz', 'json', '3ds', 'pcd'], SUPPORTED_ARCHIVES: ['zip', 'rar', 'tar', 'xz', 'gz'], camera: null, scene: null, activeScene: 0, renderer: null, stats: null, controls: null, loader: null, ambientLight: null, dirLight: null, dirLightTarget: null, cameraLight: null, cameraLightTarget: null, dirLights: [], imported: null, mainObject: [], metadataContentTech: null, mainCanvas: null, distanceGeometry: new THREE.Vector3(), metadataUrl: null, iiifConfigURL: {url: "https://raw.githubusercontent.com/IIIF/3d/main/manifests/4_transform_and_position/model_transform_scale_position.json", name: "Inbuilt"}, testModelURL: 'https://raw.githubusercontent.com/IIIF/3d/main/assets/astronaut/astronaut.glb', clock: null, FULLSCREEN: false, mixer: null, cameraTween: null, targetTween: null, container: null, viewerWrapper: null, scrollTop: null, rect: null, fileObject: { originalPath: '', filename: '', basename: '', extension: '', path: '', uri: '', newExtension: '', relativePath: '', autopath: '' }, bottomLineGUI: null, loadedFile: null, fileElement: null, COPYRIGHTS: false, EXIT_CODE: 1, gridSize: null, noMTL: false, canvasText: null, viewEntity: null, actionMenu: null, actionMenuToggle: null, actionMenuPanel: null, mainMenuButton: null, fullscreenMode: null, themeMode: null, languageMode: null, editorToolbar: null, editorToolbarButtons: {}, isToolbarExpanded: false, editorSecondaryKeys: [], downloadModel: null, embedConfiguratorPanel: null, embedConfigInputs: null, embedConfigPreviewFrame: null, embedMissingSourceNotified: false, wireframeMode: false, environmentMapEnabled: true, environmentMapPreset: "neutral", environmentMapIntensity: 0.5, currentTheme: "dark", currentLanguage: "en", loadingLog: null, loadingLogMessageKeys: [ "loadingLog.loadingAssets", "loadingLog.loadingModel", "loadingLog.loadingTextures", "loadingLog.preparingGeometry", "loadingLog.settingUpLighting", "loadingLog.settingUpMaterials", "loadingLog.compilingShaders", "loadingLog.fetchingMetadata", "loadingLog.modelLoaded", ], processingLoadingStepKeys: [ "processingSteps.preparingModel", "processingSteps.convertingToTransmissionFormat", "processingSteps.renderingThumbnails", "processingSteps.savingEntity", "processingSteps.finalizing3dModel", "processingSteps.initializingViewer", ], THEME_STORAGE_KEY: "manifesto-dark-mode", LANGUAGE_STORAGE_KEY: "viewer-language", I18N: VIEWER_I18N, GESTURE: {handPx: 55, period: 5.5, rotate: false, active: false, target: new THREE.Vector3(), startTime: 0, baseAngle: 0, orbitAngle: THREE.MathUtils.degToRad(15), easeInTime: 2.25}, lastTime: null, originalMetadata: [], spinnerContainer: null, spinnerElement: null, loadingLog: null, guiContainer: null, noticeContainer: null, statusNotice: null, statusNoticeTimer: null, statusNoticeHideTimer: null, statusNoticeQueue: [], statusNoticeActive: false, statusNoticeCurrent: null, pickingHint: null, clippingHint: null, metadataContainer: null, spinner: null, circle: null, lilGui: null, raycaster: new THREE.Raycaster(), pointer: new THREE.Vector2(), onUpPosition: new THREE.Vector2(), onDownPosition: new THREE.Vector2(), bottomOffsetFullscreen: 0, geometry: new THREE.BoxGeometry(20, 20, 20), transformControl: null, transformControlLight: null, transformControlLightTarget: null, transformControlClippingPlaneX: null, transformControlClippingPlaneY: null, transformControlClippingPlaneZ: null, cameraCoords: null, helperObjects: [], lightObjects: [], lightHelper: null, lightHelperTarget: null, selectedObject: false, selectedObjects:[], selectedFaces: [], annotationEntries: [], annotationDialog: null, annotationDialogHost: null, annotationDialogTitleInput: null, annotationDialogDescriptionInput: null, materialsDialog: null, materialsDialogSelect: null, materialsDialogInputs: null, materialsDialogPosition: null, materialsDialogDragging: false, annotationTargetFaceKeys: [], annotationBatchGroupId: "", annotationPOIGroup: null, annotationPOIMarkers: [], annotationPOITooltip: null, annotationPOITooltipTitle: null, annotationPOITooltipTarget: null, annotationImportInput: null, pendingAnnotationsXml: "", pickingTexture: null, windowHalfX: null, windowHalfY: null, transformType: "", transformText: { "Transform 3D Object": "", "Transform Light": "", "Transform Mode": "local", }, materialsPropertiesText: { "Edit material": "", }, materialsEditorObject: null, materialsList: [], selectedMaterialUuid: null, materialSelectionController: null, materialGuiControls: null, pickingStats: { "Selected faces": 0, }, colors: { DirectionalLight: "0xFFFFFF", AmbientLight: "0x404040", CameraLight: "0xFFFFFF", BackgroundColor: "#FFFFFF", BackgroundColorOuter: "#999999", }, materialProperties: { color: "0xFFFFFF", emissiveColor: "0x404040", emissive: 1, metalness: 0, }, intensity: { startIntensityDir: 1, startIntensityAmbient: 1, startIntensityCamera: 1, }, saveProperties: { Position: true, Rotation: true, Scale: true, Camera: true, DirectionalLight: true, AmbientLight: true, CameraLight: true, BackgroundColor: true, BackgroundColorOuter: true, }, backgroundType: { "Background Type": "gradient" }, backgroundOuterFolder: null, pickingMode: false, EDITOR: false, RULER_MODE: false, linePoints: [], gui: null, hierarchyFolder: null, GUILength: 35, zoomImage: 1, ZOOM_SPEED_IMAGE: 0.1, loadedFile: "", archiveType: "", planeParams: { planeX: { constantX: 0, negated: false, displayHelperX: false, }, planeY: { constantY: 0, negated: false, displayHelperY: false, }, planeZ: { constantZ: 0, negated: false, displayHelperZ: false, }, outline: { visible: false, }, clippingMode: { x: false, y: false, z: false, }, }, clippingPlanes: null, planeHelpers: [], clippingFolder: null, propertiesFolder: null, planeObjects: [], editorFolder: null, metadataFolder: null, materialsFolder: null, pickingModeController: null, distanceMeasurementController: null, clearSelectedFacesController: null, selectedFacesCountController: null, addAnnotationController: null, textMesh: null, textMeshDistance: null, ruler: [], rulerObject: null, lastPickedFace: { id: "", object: "", faceIndex: null, overlay: null }, loadedTimes: 0, _ext: '', DFG_ASSETS: '', isLightweight: false, urlOptions: { model: null, id: null, theme: null, language: null, autoRotate: null, autoRotateSpeed: null, disableInteraction: false, hideUi: false, hideMetadata: false, cameraPosition: null, cameraTarget: null, cameraFov: null, presentationMode: false, sandboxMode: false, }, keyboardStep: { rotate: THREE.MathUtils.degToRad(2.25), rotateFast: THREE.MathUtils.degToRad(6), panFactor: 0.04, zoomFactor: 1.08, }, keyboardTweenDurationMs: 150, lastKeyboardHintAt: 0, keyboardHintCooldownMs: 45000, keyboardHintAfterFocusDelayMs: 1800, lastWindowFocusAt: 0, cleanupCallbacks: [], resizeObserver: null, i18nGui: {}, showNotifications: true, getE2EModelOverride() { if (!window.__E2E__) return null; const model = new URLSearchParams(window.location.search).get('e2eModel'); return model || null; }, ensureE2EState() { if (!window.__E2E__) return null; if (!window.viewer || window.viewer.e2eMode !== true) { window.viewer = { e2eMode: true, modelLoaded: false, errors: [], toasts: [], get camera() { return core.camera; }, get scene() { return core.scene; }, }; } else { window.viewer.errors ??= []; window.viewer.toasts ??= []; } return window.viewer; }, recordE2EError(error) { if (!window.__E2E__) return; const state = this.ensureE2EState(); const message = error instanceof Error ? error.message : String(error); state.errors.push(message); }, addCleanup(callback) { if (typeof callback === "function") { this.cleanupCallbacks.push(callback); } }, bindEventListener(target, type, handler, options) { if (!target || typeof target.addEventListener !== "function") return; target.addEventListener(type, handler, options); this.addCleanup(() => target.removeEventListener(type, handler, options)); }, closeActionMenu() { if (this.actionMenuToggle) { this.actionMenuToggle.checked = false; } if (this.languageModeDropdown) { this.languageModeDropdown.hidden = true; } this.updateEditorToolbarState(); }, stopHandMode() { const g = core.GESTURE; if (g) { g.rotate = false; g.active = false; g.baseAngle = null; g.target = null; } if (core.handHint) { core.handHint.hidden = true; core.handHint.classList.remove("hand-drag-animate"); } if (core.controls) { core.controls.enabled = true; core.controls.update?.(); } }, getEditorToolbarIcon(icon) { return getEditorToolbarIcon(icon); }, toggleToolbarExpanded() { return toggleEditorToolbarExpanded(this); }, syncEditorToolbarSecondaryTrayWidth() { return syncEditorToolbarSecondaryTrayWidth(this); }, getEditorToolbarHost() { return getEditorToolbarHost(this); }, attachEditorToolbar() { return attachEditorToolbar(this); }, createEditorToolbar() { return createEditorToolbar(this); }, updateEditorToolbarLabels() { return syncEditorToolbarLabels(this); }, isEditorAdvancedPanelVisible() { if (!this.editorFolder?.domElement) return false; return this.editorFolder.domElement.style.display !== "none"; }, setEditorAdvancedPanelVisible(visible) { if (!this.editorFolder) return; if (visible) { if (core.guiContainer) { core.guiContainer.hidden = false; } if (core.gui?.domElement?.style) { core.gui.domElement.style.visibility = "visible"; } this.editorFolder.show?.(); } else { this.editorFolder.hide?.(); } this.updateEditorToolbarLabels(); this.updateEditorToolbarState(); }, toggleEditorAdvancedPanel() { this.setEditorAdvancedPanelVisible(!this.isEditorAdvancedPanelVisible()); }, toggleCameraProjection () { if (!core.camera) return; const isPerspective = core.camera.isPerspectiveCamera === true; Viewer.setCameraProjection(isPerspective ? "orthographic" : "perspective"); }, updateOrthoFrustum(camera, width, height) { const target = core.controls?.target || new THREE.Vector3(0, 0, 0); const distance = core.camera.position.distanceTo(target); const aspect = width / height; const frustumHeight = distance; const frustumWidth = frustumHeight * aspect; camera.left = -frustumWidth / 2; camera.right = frustumWidth / 2; camera.top = frustumHeight / 2; camera.bottom = -frustumHeight / 2; camera.updateProjectionMatrix(); }, setCameraProjection(projection) { if (!core.camera) return; const currentProjection = core.camera.isPerspectiveCamera ? "perspective" : "orthographic"; if (projection === currentProjection) return; const aspect = core.CONFIG.viewer.canvasDimensions.x / core.CONFIG.viewer.canvasDimensions.y; const target = core.controls?.target || new THREE.Vector3(0, 0, 0); const distance = core.camera.position.distanceTo(target); let newCamera; if (projection === "orthographic") { const fov = THREE.MathUtils.degToRad(core.camera.fov); const viewHeight = 2 * distance * Math.tan(fov / 2); const viewWidth = viewHeight * aspect; newCamera = new THREE.OrthographicCamera( -viewWidth / 2, viewWidth / 2, viewHeight / 2, -viewHeight / 2, 0.1, 2000 ); newCamera.zoom = 1; } else { newCamera = new THREE.PerspectiveCamera( 50, aspect, 0.1, 2000 ); } newCamera.position.copy(core.camera.position); newCamera.quaternion.copy(core.camera.quaternion); newCamera.up.copy(core.camera.up); newCamera.updateProjectionMatrix(); core.camera = newCamera; if (core.controls) { core.controls.object = core.camera; core.controls.update(); } this.updateCamera(); this.updateFullscreenButtonIcon(); this.updateEditorToolbarLabels(); this.updateEditorToolbarState(); }, updateCamera () { if (!core.camera || !core.controls) return; core.controls.object = core.camera; core.controls.update(); }, toggleWireframeMode() { if (typeof core.scene === "undefined") return; core.wireframeMode = !core.wireframeMode; core.scene.traverse((child) => { if (child.material) { child.material.wireframe = core.wireframeMode; child.material.needsUpdate = true; child.material.wireframeLinewidth = 1; } }); this.updateEditorToolbarLabels(); this.updateEditorToolbarState(); }, async setEnvironmentMapEnabled(enabled) { this.environmentMapEnabled = enabled !== false; setCore("environmentMapEnabled", this.environmentMapEnabled); await syncSceneEnvironment(this.environmentMapEnabled); this.updateEditorToolbarState(); }, async setEnvironmentMapPreset(preset) { this.environmentMapPreset = preset || "studio"; setCore("environmentMapPreset", this.environmentMapPreset); // If environment is enabled, sync with the new preset const isEnabled = (core.scene?.environmentIntensity ?? 0) > 0; if (isEnabled) { await syncSceneEnvironment(true, this.environmentMapPreset); } this.updateEditorToolbarState(); }, async toggleEnvironmentMap() { await this.setEnvironmentMapEnabled(!this.environmentMapEnabled); }, addHierarchySubmenuItem(name, meshId) { if (!this.hierarchySubmenu) return; const subButton = document.createElement("button"); subButton.type = "button"; subButton.className = "viewer-editor-tool viewer-editor-tool_submenu-button viewer-editor-hierarchy-item"; subButton.dataset.tool = `hierarchy-item-${meshId}`; subButton.innerHTML = `${name}`; subButton.setAttribute("title", name); subButton.setAttribute("aria-label", name); const textLabel = document.createElement("span"); textLabel.className = "viewer-editor-hierarchy-submenu-label"; textLabel.style.marginLeft = "8px"; textLabel.style.marginRight = "8px"; textLabel.textContent = name; textLabel.style.maxWidth = "120px"; textLabel.style.overflow = "hidden"; textLabel.style.textOverflow = "ellipsis"; textLabel.style.whiteSpace = "nowrap"; textLabel.style.display = "inline-block"; subButton.appendChild(textLabel); this.bindEventListener(subButton, "click", (event) => { event.stopPropagation(); Viewer.selectObjectHierarchy(meshId, core.container); }); this.hierarchySubmenuList.appendChild(subButton); this.hierarchySubmenuButtons[meshId] = subButton; this.updateHierarchySubmenuState(); }, clearHierarchySubmenu() { if (!this.hierarchySubmenuList) return; this.hierarchySubmenuList.innerHTML = ""; this.hierarchySubmenuButtons = {}; this.updateHierarchySubmenuState(); }, updateHierarchySubmenuState() { return updateHierarchySubmenuState(this); }, clearHierarchySelection() { if (!Array.isArray(core.selectedObjects) || core.selectedObjects.length === 0) { Viewer.updateHierarchySubmenuState(); return; } core.selectedObjects.forEach((item) => { const object = core.scene?.getObjectById?.(item.id); if (!object || !item?.originalMaterial) return; object.material = item.originalMaterial; object.material.needsUpdate = true; }); core.selectedObjects.length = 0; Viewer.updateHierarchySubmenuState(); }, toggleStatsVisibility() { if (typeof core.stats === "undefined" || !core.stats?.dom) return; const isVisible = core.stats.dom.style.visibility !== "visible"; core.stats.dom.style.visibility = isVisible ? "visible" : "hidden"; this.updateEditorToolbarState(); }, setPerformanceMode(value) { if (typeof core.renderer !== "undefined") { core.renderer.powerPreference = value; } if (!core.CONFIG.viewer) { core.CONFIG.viewer = {}; } core.CONFIG.viewer.performanceMode = value; this.updateEditorToolbarState(); }, updateStatisticsSubmenuState() { return updateStatisticsSubmenuState(this); }, toggleMainMenu() { if (!this.actionMenuToggle) return; this.actionMenuToggle.checked = !this.actionMenuToggle.checked; if (!this.actionMenuToggle.checked && this.languageModeDropdown) { this.languageModeDropdown.hidden = true; } this.updateEditorToolbarLabels(); this.updateEditorToolbarState(); }, setObjectTransformMode(mode = "") { const normalizedMode = ["translate", "rotate", "scale"].includes(mode) ? mode : ""; if (normalizedMode && !core.helperObjects?.[0]) return; if (core.i18nGui.transformObjectController?.setValue) { core.i18nGui.transformObjectController.setValue(normalizedMode); } else { this.transformText["Transform 3D Object"] = normalizedMode; } this.updateEditorToolbarState(); }, toggleObjectTransformMode(mode = "") { const nextMode = this.transformText["Transform 3D Object"] === mode ? "" : mode; this.setObjectTransformMode(nextMode); }, setLightTransformMode(mode = "") { const normalizedMode = ["translate", "rotate"].includes(mode) ? mode : ""; if (core.i18nGui.transformLightController?.setValue) { core.i18nGui.transformLightController.setValue(normalizedMode); } else { this.transformText["Transform Light"] = normalizedMode; } this.updateEditorToolbarState(); }, toggleLightTransformMode(mode = "") { const nextMode = this.transformText["Transform Light"] === mode ? "" : mode; this.setLightTransformMode(nextMode); }, openLightFolder(name) { this.setEditorAdvancedPanelVisible(true); const folder = core.i18nGui?.[name]; if (folder?.open) { folder.open(); } }, togglePickingMode() { this.pickingMode = !this.pickingMode; toastHelper(this.pickingMode ? "facePickingEnabled" : "facePickingDisabled", { duration: 1400 }); if (!this.pickingMode) { this.restoreLastPickedFace(); this.clearSelectedFaces(); } else { this.RULER_MODE = false; this.updateDistanceMeasurementControllerLabel(); } this.updatePickingModeControllerLabel(); this.updatePickingControlsVisibility(); this.updateEditorToolbarLabels(); this.updateEditorToolbarState(); }, toggleDistanceMeasurement() { this.RULER_MODE = !this.RULER_MODE; if (this.RULER_MODE) { toastHelper("distanceEnabled", { duration: 2600 }); toastHelper("distanceHint", { duration: 5200 }); } else { toastHelper(this.RULER_MODE ? "distanceModeEnabled" : "distanceModeDisabled"); } if (!this.RULER_MODE) { this.ruler.forEach((r) => { core.scene.remove(r); }); this.rulerObject = new THREE.Object3D(); this.ruler = []; this.linePoints = []; } else { this.pickingMode = false; this.restoreLastPickedFace(); this.clearSelectedFaces(); this.updatePickingModeControllerLabel(); this.updatePickingControlsVisibility(); } this.updateDistanceMeasurementControllerLabel(); this.updateEditorToolbarLabels(); this.updateEditorToolbarState(); }, toggleClippingPlanesPanel() { this.clippingMode = !this.clippingMode; if (this.clippingMode) { toastHelper("facePickingEnabled", { duration: 2600 }); toastHelper("clippingPlanes", { duration: 5200 }); } else { toastHelper("facePickingDisabled"); if (core.planeHelpers?.length >= 3) { core.planeHelpers.forEach((helper) => { if (helper) helper.visible = false; }); } core.planeParams.clippingMode.x = false; core.planeParams.clippingMode.y = false; core.planeParams.clippingMode.z = false; if (core.outlineClipping) { core.outlineClipping.visible = false; } if (this.transformControlClippingPlaneX) { this.transformControlClippingPlaneX.detach(); } if (this.transformControlClippingPlaneY) { this.transformControlClippingPlaneY.detach(); } if (this.transformControlClippingPlaneZ) { this.transformControlClippingPlaneZ.detach(); } } this.updateClippingPlanesControllerLabel(); this.updateClippingPlanesControlsVisibility(); this.updateEditorToolbarLabels(); this.updateEditorToolbarState(); }, updateClippingPlanesControllerLabel() { if (core.i18nGui.clippingPlanesController?.name) { core.i18nGui.clippingPlanesController.name(this.clippingMode ? t("controls.disableClippingPlanesMode", "Disable clipping planes mode") : t("controls.enableClippingPlanesMode", "Enable clipping planes mode")); } }, updateClippingPlanesControlsVisibility() { if (this.transformControlClippingPlaneX) { this.transformControlClippingPlaneX.visible = this.clippingMode; } if (this.transformControlClippingPlaneY) { this.transformControlClippingPlaneY.visible = this.clippingMode; } if (this.transformControlClippingPlaneZ) { this.transformControlClippingPlaneZ.visible = this.clippingMode; } }, toggleClippingPlaneHelper(axis) { const axisIndex = { x: 0, y: 1, z: 2 }[axis]; const planeHelper = core.planeHelpers?.[axisIndex]; const control = this[`transformControlClippingPlane${axis.toUpperCase()}`]; if (!planeHelper) return; const active = !Boolean(core.planeParams.clippingMode?.[axis]); core.planeParams.clippingMode[axis] = planeHelper.visible = active; if (active) { control?.attach?.(planeHelper); if (core.planeParams.outline.visible) core.outlineClipping.visible = true; } else { control?.detach?.(); if ( !core.planeParams.clippingMode.x && !core.planeParams.clippingMode.y && !core.planeParams.clippingMode.z && !core.planeParams.outline.visible ) { core.outlineClipping.visible = false; } } toastHelper("clippingHelperToggle", "info", { axis: axis.toUpperCase(), state: active, }); this.refreshClippingHintVisibility(); this.updateClippingPlanesSubmenuState(); }, toggleClippingPlaneVisible() { const visible = !Boolean(core.planeParams.outline.visible); core.planeParams.outline.visible = visible; if (core.outlineClipping) core.outlineClipping.visible = visible; this.updateClippingPlanesSubmenuState(); }, refreshClippingHintVisibility() { const clippingMode = core.planeParams?.clippingMode || {}; if (this.clippingHint) { this.clippingHint.hidden = !(clippingMode.x || clippingMode.y || clippingMode.z); } }, updateClippingPlanesSubmenuState() { return updateClippingPlanesSubmenuState(this); }, updateAnnotateSubmenuState() { if (!this.annotateSubmenuButtons) return; }, updateLightsSubmenuState() { return updateLightsSubmenuState(this); }, async saveEditorMetadata() { return persistEditorMetadata(this); }, updateEditorToolbarState() { return syncEditorToolbarState(this); }, tFormat(key, params = {}, fallback = "") { const template = t(key, fallback); return String(template).replace(/\{(\w+)\}/g, (_match, token) => { const replacement = params?.[token]; return replacement == null ? "" : String(replacement); }); }, parseBooleanParam(value) { if (value == null) return null; const normalizedValue = String(value).trim().toLowerCase(); if (["1", "true", "yes", "on"].includes(normalizedValue)) return true; if (["0", "false", "no", "off"].includes(normalizedValue)) return false; return null; }, parseFloatParam(value) { if (value == null || value === "") return null; const parsed = Number.parseFloat(String(value)); return Number.isFinite(parsed) ? parsed : null; }, parseVector2Param(value) { if (value == null || value === "") return null; const cleaned = String(value).replace(/[\[\]()]/g, " ").trim(); const parts = cleaned.split(/[\s,;|]+/).filter(Boolean); if (parts.length !== 2) return null; const x = Number.parseFloat(parts[0]); const y = Number.parseFloat(parts[1]); if (!Number.isFinite(x) || !Number.isFinite(y)) return null; return new THREE.Vector2(x, y); }, parseVector3Param(value) { if (value == null || value === "") return null; const cleaned = String(value).replace(/[\[\]()]/g, " ").trim(); const parts = cleaned.split(/[\s,;|]+/).filter(Boolean); if (parts.length !== 3) return null; const x = Number.parseFloat(parts[0]); const y = Number.parseFloat(parts[1]); const z = Number.parseFloat(parts[2]); if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null; return new THREE.Vector3(x, y, z); }, formatVector3Param(vector) { if (!vector || typeof vector !== "object") return null; const x = Number(vector.x); const y = Number(vector.y); const z = Number(vector.z); if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return null; return `${x.toFixed(4)},${y.toFixed(4)},${z.toFixed(4)}`; }, parseUrlOptions() { const params = new URLSearchParams(window.location.search); const modelFromQuery = params.get("model") || params.get("src"); const themeFromQuery = (params.get("theme") || "").trim().toLowerCase(); const languageFromQuery = this.normalizeLanguage(params.get("lang") || params.get("language")); const autoRotateFromQuery = this.parseBooleanParam(params.get("autorotate")); const disableInteractionFromQuery = this.parseBooleanParam(params.get("disableInteraction")); const hideUiFromQuery = this.parseBooleanParam(params.get("hideUi")); const hideMetadataFromQuery = this.parseBooleanParam(params.get("hideMetadata")); const presentationModeFromQuery = this.parseBooleanParam(params.get("presentationMode")); const sandboxModeFromQuery = this.parseBooleanParam(params.get("sandbox")); if (presentationModeFromQuery !== null) { core.PRESENTATION_MODE = presentationModeFromQuery; } if (sandboxModeFromQuery !== null) { core.SANDBOX_MODE = sandboxModeFromQuery; } this.urlOptions = { model: modelFromQuery || null, id: params.get("id") || null, theme: themeFromQuery === "light" || themeFromQuery === "dark" ? themeFromQuery : null, language: languageFromQuery, autoRotate: autoRotateFromQuery, autoRotateSpeed: this.parseFloatParam(params.get("autorotateSpeed")), disableInteraction: disableInteractionFromQuery === true, hideUi: hideUiFromQuery === true, hideMetadata: hideMetadataFromQuery === true, cameraPosition: this.parseVector3Param(params.get("camPos") || params.get("cameraPos")), cameraTarget: this.parseVector3Param(params.get("camTarget") || params.get("cameraTarget")), cameraFov: this.parseFloatParam(params.get("fov")), presentationMode: core.PRESENTATION_MODE === true, sandboxMode: core.SANDBOX_MODE === true, scale: this.parseVector2Param(params.get("scale")) ?? null, showNotifications: this.parseBooleanParam(params.get("showNotifications")), }; }, setGuiFolderTitle(folder, title) { if (!folder || !title) return; if (typeof folder.title === "function") { folder.title(title); return; } if (folder.$title) { folder.$title.textContent = title; } folder._title = title; }, refreshOptionController(controller, optionsMap) { if (!controller || typeof controller.options !== "function") return; const currentValue = typeof controller.getValue === "function" ? controller.getValue() : undefined; controller.options(optionsMap); if (currentValue !== undefined && typeof controller.setValue === "function") { controller.setValue(currentValue); } if (typeof controller.updateDisplay === "function") { controller.updateDisplay(); } }, updatePickingModeControllerLabel() { if (!this.pickingModeController?.name) return; this.pickingModeController.name( this.pickingMode ? t("controls.disablePickingMode", "Disable picking mode") : t("controls.enablePickingMode", "Enable picking mode") ); }, updateMaterialControllerLabel() { if (!this.materialController?.name) return; this.materialController.name( this.materialMode ? t("controls.disableMaterialMode", "Disable material mode") : t("controls.enableMaterialMode", "Enable material mode") ); }, updateDistanceMeasurementControllerLabel() { if (!this.distanceMeasurementController?.name) return; this.distanceMeasurementController.name( this.RULER_MODE ? t("controls.disableDistanceMeasurement", "Disable distance measurement") : t("controls.enableDistanceMeasurement", "Enable distance measurement") ); }, getDistanceMeasurementScaleMeters() { const configuredScale = Number(core.CONFIG?.viewer?.measurement?.modelUnitInMeters); if (Number.isFinite(configuredScale) && configuredScale > 0) return configuredScale; return 1; }, formatMeasuredDistance(rawDistanceInModelUnits) { const scaleMeters = this.getDistanceMeasurementScaleMeters(); const meters = rawDistanceInModelUnits * scaleMeters; if (!Number.isFinite(meters)) { return { text: "0 mm", meters: 0, scaleMeters }; } if (meters >= 1) { return { text: `${meters.toFixed(2)} m`, meters, scaleMeters }; } if (meters >= 0.01) { return { text: `${(meters * 100).toFixed(1)} cm`, meters, scaleMeters }; } return { text: `${(meters * 1000).toFixed(0)} mm`, meters, scaleMeters }; }, updateSelectedFacesControllerLabel() { if (!this.selectedFacesCountController?.name) return; this.selectedFacesCountController.name(t("controls.selectedFaces", "Selected faces")); }, updatePickingHintVisibility() { if (!this.pickingHint) return; const hasSelectedFaces = Array.isArray(this.selectedFaces) && this.selectedFaces.length > 0; this.pickingHint.hidden = !this.pickingMode || hasSelectedFaces; this.updateClippingHintVisibility(); }, updateClippingHintVisibility() { if (!this.clippingHint) return; const clippingMode = this.planeParams?.clippingMode || {}; const hasActiveClipping = Boolean(clippingMode.x || clippingMode.y || clippingMode.z); const pickingHintVisible = Boolean(this.pickingHint && this.pickingHint.hidden === false); this.clippingHint.hidden = !hasActiveClipping || pickingHintVisible; }, updatePickingControlsVisibility() { const method = this.pickingMode ? "show" : "hide"; this.clearSelectedFacesController?.[method]?.(); this.selectedFacesCountController?.[method]?.(); this.updateAddAnnotationControllerState(); this.updatePickingHintVisibility(); this.updateEditorToolbarLabels(); this.updateEditorToolbarState(); }, updateAddAnnotationControllerState() { if (!this.addAnnotationController) return; this.addAnnotationController.enable?.(); }, getKeyboardShortcutsText() { return [ t("shortcuts.mouse"), t("shortcuts.keyboard"), t("shortcuts.touch"), core.CONFIG?.viewer?.enableDragAndDrop === true ? t("shortcuts.dragAndDrop") : null ].join("\n"); }, getSupportedFormatsText() { return core.SUPPORTED_EXTENSIONS.map((extension) => extension.toUpperCase()).join(", "); }, getSupportedArchiveFormatsText() { return core.SUPPORTED_ARCHIVES.map((extension) => extension.toUpperCase()).join(", "); }, updateDragAndDropHint() { if (!this.dragAndDropHint) return; if (core.CONFIG?.viewer?.enableDragAndDrop === true) { this.dragAndDropHint.textContent = t("shortcuts.dragAndDrop", "You can also drag and drop a model file here to load it"); this.dragAndDropHint.hidden = false; } else { this.dragAndDropHint.hidden = true; } }, maybeShowKeyboardHint() { try { if (window.localStorage.getItem("viewerHintSeen") !== "1") return; } catch (_err) { // If storage is unavailable, keep previous behavior. } if (document.visibilityState !== "visible" || !document.hasFocus()) return; const now = Date.now(); if (now - this.lastWindowFocusAt < this.keyboardHintAfterFocusDelayMs) return; if (this.pickingMode) return; const clippingMode = this.planeParams?.clippingMode || {}; if (clippingMode.x || clippingMode.y || clippingMode.z) return; if (!core.handHint?.hidden || core.GESTURE?.active) return; if (now - this.lastKeyboardHintAt < this.keyboardHintCooldownMs) return; this.lastKeyboardHintAt = now; this.showStatusNotice(this.getKeyboardShortcutsText(), 7400); }, isInteractiveTextInput(element) { if (!element || typeof element.closest !== "function") return false; return Boolean( element.closest("input, textarea, select, [contenteditable='true'], [contenteditable='']") ); }, isViewerKeyboardActive(event) { if (this.isInteractiveTextInput(event?.target)) return false; if (!core.renderer?.domElement) return false; const active = document.activeElement; return active === core.renderer.domElement || core.renderer.domElement.contains(active); }, isPointerDirectlyOverCanvas(event) { if (!core.renderer?.domElement || !event) return false; const x = Number(event.clientX); const y = Number(event.clientY); if (!Number.isFinite(x) || !Number.isFinite(y)) return false; const topElement = document.elementFromPoint(x, y); if (!topElement) return false; return topElement === core.renderer.domElement; }, animateKeyboardCameraTo(nextCameraPosition, nextTarget) { if (!core.camera || !core.controls || !nextCameraPosition || !nextTarget) return; const startCamera = core.camera.position.clone(); const startTarget = core.controls.target.clone(); const targetCamera = nextCameraPosition.clone(); const targetControls = nextTarget.clone(); const duration = this.keyboardTweenDurationMs; core.cameraTween?.stop?.(); core.targetTween?.stop?.(); core.cameraTween = new TWEEN.Tween(startCamera) .to(targetCamera, duration) .easing(TWEEN.Easing.Quadratic.Out) .onUpdate(() => { core.camera.position.copy(startCamera); core.cameraLight?.position.copy(startCamera); core.camera.updateProjectionMatrix(); }) .onComplete(() => { core.camera.position.copy(targetCamera); core.cameraLight?.position.copy(targetCamera); core.camera.updateProjectionMatrix(); }); core.targetTween = new TWEEN.Tween(startTarget) .to(targetControls, duration) .easing(TWEEN.Easing.Quadratic.Out) .onUpdate(() => { core.controls.target.copy(startTarget); core.controls.update(); }) .onComplete(() => { core.controls.target.copy(targetControls); core.controls.update(); }); core.cameraTween.start(); core.targetTween.start(); }, rotateCameraByKeyboard(deltaTheta = 0, deltaPhi = 0) { if (!core.camera || !core.controls) return; const target = core.controls.target.clone(); const offset = core.camera.position.clone().sub(target); if (offset.lengthSq() === 0) return; const spherical = new THREE.Spherical().setFromVector3(offset); const minPolar = 0.05; const maxPolar = Math.PI - 0.05; spherical.theta += deltaTheta; spherical.phi = THREE.MathUtils.clamp(spherical.phi + deltaPhi, minPolar, maxPolar); offset.setFromSpherical(spherical); const nextCamera = target.clone().add(offset); this.animateKeyboardCameraTo(nextCamera, target); }, panCameraByKeyboard(directionX = 0, directionY = 0) { if (!core.camera || !core.controls) return; const distance = core.camera.position.distanceTo(core.controls.target) || 1; const panStep = distance * this.keyboardStep.panFactor; const forward = core.controls.target.clone().sub(core.camera.position).normalize(); const right = new THREE.Vector3().crossVectors(forward, core.camera.up).normalize(); const up = core.camera.up.clone().normalize(); const panOffset = right.multiplyScalar(directionX * panStep).add(up.multiplyScalar(directionY * panStep)); const nextCamera = core.camera.position.clone().add(panOffset); const nextTarget = core.controls.target.clone().add(panOffset); this.animateKeyboardCameraTo(nextCamera, nextTarget); }, zoomCameraByKeyboard(zoomIn = true) { if (!core.camera || !core.controls) return; const factor = zoomIn ? 1 / this.keyboardStep.zoomFactor : this.keyboardStep.zoomFactor; const offset = core.camera.position.clone().sub(core.controls.target); let nextDistance = offset.length() * factor; if (Number.isFinite(core.controls.minDistance)) { nextDistance = Math.max(core.controls.minDistance, nextDistance); } if (Number.isFinite(core.controls.maxDistance) && core.controls.maxDistance > 0) { nextDistance = Math.min(core.controls.maxDistance, nextDistance); } if (nextDistance <= 0) return; offset.setLength(nextDistance); const nextTarget = core.controls.target.clone(); const nextCamera = nextTarget.clone().add(offset); this.animateKeyboardCameraTo(nextCamera, nextTarget); }, toggleAutoRotateByKeyboard() { if (!core.controls) return; core.controls.autoRotate = !core.controls.autoRotate; this.showStatusNotice( core.controls.autoRotate ? "Auto-rotate enabled" : "Auto-rotate disabled", 1400 ); this.updateEmbedConfiguratorPreview(); }, onViewerKeyDown(event) { if (!Viewer.isViewerKeyboardActive(event)) return; const isFast = event.shiftKey; const rotateStep = isFast ? Viewer.keyboardStep.rotateFast : Viewer.keyboardStep.rotate; const isPanMode = event.ctrlKey || event.metaKey; let handled = false; switch (event.key) { case "ArrowLeft": if (isPanMode) Viewer.panCameraByKeyboard(-1, 0); else Viewer.rotateCameraByKeyboard(-rotateStep, 0); handled = true; break; case "ArrowRight": if (isPanMode) Viewer.panCameraByKeyboard(1, 0); else Viewer.rotateCameraByKeyboard(rotateStep, 0); handled = true; break; case "ArrowUp": if (isPanMode) Viewer.panCameraByKeyboard(0, 1); else Viewer.rotateCameraByKeyboard(0, -rotateStep); handled = true; break; case "ArrowDown": if (isPanMode) Viewer.panCameraByKeyboard(0, -1); else Viewer.rotateCameraByKeyboard(0, rotateStep); handled = true; break; case "+": case "=": Viewer.zoomCameraByKeyboard(true); handled = true; break; case "-": case "_": Viewer.zoomCameraByKeyboard(false); handled = true; break; case " ": case "Spacebar": Viewer.toggleAutoRotateByKeyboard(); handled = true; break; default: break; } if (handled) { Viewer.maybeShowKeyboardHint(); event.preventDefault(); event.stopPropagation(); } }, updateFullscreenButtonIcon() { if (core.editorToolbar) { if (Viewer.FULLSCREEN) { core.editorToolbar.classList.add("with-fullscreen"); } else { core.editorToolbar.classList.remove("with-fullscreen"); } } }, cleanupRuntimeBindings() { while (this.cleanupCallbacks.length > 0) { const callback = this.cleanupCallbacks.pop(); try { callback(); } catch (error) { console.warn("Viewer cleanup callback failed:", error); } } if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } }, cleanupTransientUI() { const manifestoForm = document.getElementById("form-manifesto"); if (manifestoForm) { manifestoForm.remove(); } }, reportError(error, options = {}) { return reportViewerError(error, { consoleLabel: "Viewer error:", ...options, }); }, disposeMaterial(material) { if (!material) return; const materials = Array.isArray(material) ? material : [material]; materials.forEach((entry) => { if (!entry || typeof entry !== "object") return; Object.values(entry).forEach((value) => { if (value && typeof value === "object" && value.isTexture === true) { value.dispose(); } }); entry.dispose?.(); }); }, disposeObjectResources(object) { if (!object) return; const disposeNode = (node) => { if (!node || typeof node !== "object") return; node.geometry?.dispose?.(); Viewer.disposeMaterial(node.material); }; if (Array.isArray(object)) { object.forEach((entry) => Viewer.disposeObjectResources(entry)); return; } object.traverse?.((child) => disposeNode(child)); }, removeAndDisposeFromScene(object) { if (!object) return; if (Array.isArray(object)) { object.forEach((entry) => Viewer.removeAndDisposeFromScene(entry)); return; } if (object.parent) { object.parent.remove(object); } else { core.scene?.remove?.(object); } Viewer.disposeObjectResources(object); }, disposeFaceOverlay(entry) { if (!entry?.overlay) return; entry.overlay.removeFromParent(); Viewer.disposeObjectResources(entry.overlay); }, resetLoadedModelState() { Viewer.restoreLastPickedFace(); Viewer.clearSelectedFaces(); Viewer.closeAnnotationDialog(); Viewer.annotationEntries.length = 0; Viewer.pendingAnnotationsXml = ""; Viewer.clearAnnotationPOIs(); core.transformControl?.detach?.(); core.transformControlLight?.detach?.(); core.transformControlLightTarget?.detach?.(); Viewer.transformText["Transform 3D Object"] = ""; Viewer.transformText["Transform Light"] = ""; Viewer.pickingMode = false; Viewer.RULER_MODE = false; Viewer.updateEditorToolbarLabels(); Viewer.updateEditorToolbarState(); if (core.outlineClipping) { Viewer.removeAndDisposeFromScene(core.outlineClipping); core.outlineClipping = null; setCore('outlineClipping', null); } if (Viewer.textMesh) { Viewer.removeAndDisposeFromScene(Viewer.textMesh); Viewer.textMesh = null; } if (Viewer.ruler?.length) { Viewer.ruler.forEach((item) => Viewer.removeAndDisposeFromScene(item)); } Viewer.ruler = []; Viewer.rulerObject = null; Viewer.textMeshDistance = null; if (core.mainObject?.length) { core.mainObject.forEach((obj) => Viewer.removeAndDisposeFromScene(obj)); core.mainObject.length = 0; } if (Array.isArray(core.helperObjects)) core.helperObjects.length = 0; if (Array.isArray(core.selectedObjects)) core.selectedObjects.length = 0; if (Array.isArray(Viewer.helperObjects)) Viewer.helperObjects.length = 0; if (Array.isArray(Viewer.selectedObjects)) Viewer.selectedObjects.length = 0; if (Array.isArray(Viewer.selectedFaces)) Viewer.selectedFaces.length = 0; Viewer.updateSelectedFacesCount(); Viewer.lastPickedFace = { id: "", object: "", faceIndex: null, overlay: null }; }, renderFatalError(error) { const message = this.reportError(error, { context: "Viewer initialization failed", toast: false, consoleLabel: "Viewer initialization error:", }); const container = this.container || document.getElementById(core.CONFIG?.viewer?.container || "DFG_3DViewer") || document.body; this.noticeContainer.style.bottom = "50%"; if (!container) { showToast("toasts.containerNotFound", "error", { duration: 5000 }); return; } showToast("toasts.missingFiles", "error", { duration: 5000 }); }, getDefaultConfig() { return { mainUrl: "https://dfg-repository.wisski.cloud", baseNamespace: "https://dfg-repository.wisski.cloud", metadataUrl: "https://dfg-repository.wisski.cloud", baseModulePath: "/libraries/dfg-3dviewer/assets", entity: { bundle: "bd3d7baa74856d141bcff7b4193fa128", fieldDf: "field_df", exportViewer: "field_df", idUri: "/wisski/navigate/(.*)/view", viewEntityPath: "/wisski/navigate/", attributeId: "wisski_id", metadata: { source: "IIIF", }, }, viewer: { container: "DFG_3DViewer", fileUpload: "fbf95bddee5160d515b982b3fd2e05f7", fileName: "faa602a0be629324806aef22892cdbe5", imageGeneration: "f605dc6b727a1099b9e52b3ccbdf5673", presentationMode: "false", sandboxMode: "false", lightweight: 0, scaleContainer: { x: 0.85, y: 1.4, }, editor: true, gallery: { build: true, container: "block-bootstrap5-content", imageClass: "field--name-fd6a974b7120d422c7b21b5f1f2315d9", imageId: "", buildFake: false, testImages: [], }, background: "radial-gradient(circle, #ffffff 0%, #999999 100%)", performanceMode: { Performance: "high-performance", }, measurement: { modelUnitInMeters: 1, } }, }; }, resolveBundleAssetBasePath(moduleUrl) { const pathname = moduleUrl.pathname; if (pathname.includes('/assets/')) { return new URL('../assets/', moduleUrl).pathname.replace(/\/$/, ''); } return new URL('./assets/', moduleUrl).pathname.replace(/\/$/, ''); }, async loadViewerConfig(moduleUrl, explicitConfig = null) { if (explicitConfig && Object.keys(explicitConfig).length > 0) { return explicitConfig; } const drupalCfg = typeof window !== 'undefined' ? window.drupalSettings?.dfg_3dviewer : null; if (drupalCfg && typeof drupalCfg === 'object' && Object.keys(drupalCfg).length > 0) { const { csrfToken, ...config } = drupalCfg; if (csrfToken && typeof window !== 'undefined') { window.CSRF_TOKEN = csrfToken; } console.log("Loaded viewer settings from drupalSettings", config.viewer); if (typeof window !== 'undefined' && window.__E2E__) { window.viewer = window.viewer || {}; window.viewer.configFromDrupal = true; window.viewer.configBundle = config.entity?.bundle; } return config; } const settingsPath = moduleUrl.pathname.includes('/assets/') ? '../viewer-settings.json' : './viewer-settings.json'; const url = new URL(settingsPath, moduleUrl); const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const config = await res.json(); console.log("Loaded viewer-settings.json", config.viewer); return config; }, async MainInit(explicitConfig = null) { if (window.__E2E__) { this.ensureE2EState(); } this.cleanupRuntimeBindings(); this.cleanupTransientUI(); this.resetLoadedModelState(); await new Promise(r => { if (document.readyState !== 'loading') r(); else document.addEventListener('DOMContentLoaded', r); }); const moduleUrl = new URL(import.meta.url); const bundleAssetBase = this.resolveBundleAssetBasePath(moduleUrl); //Setup core variables first to make them available in the loaders and utils setCore('viewEntity', this.viewEntity); setCore('CONFIG', this.CONFIG); setCore('loadedFile', this.loadedFile); setCore('stats', this.stats); setCore('guiContainer', this.guiContainer); setCore('lilGui', this.lilGui); setCore('gui', this.gui); setCore('i18nGui', this.i18nGui); setCore('SUPPORTED_EXTENSIONS', this.SUPPORTED_EXTENSIONS); setCore('SUPPORTED_ARCHIVES', this.SUPPORTED_ARCHIVES); setCore('enqueueStatusNotice', this.enqueueStatusNotice.bind(this)); setCore('dismissStatusNotice', this.dismissStatusNotice.bind(this)); setCore('updateClippingHintVisibility', this.updateClippingHintVisibility.bind(this)); setCore('editorToolbar', this.editorToolbar); setCore('wireframeMode', this.wireframeMode); setCore('DFG_ASSETS', bundleAssetBase); core.CONFIG = await this.loadViewerConfig(moduleUrl, explicitConfig); if (Object.keys(core.CONFIG).length === 0) { core.CONFIG = this.getDefaultConfig(); } core.CONFIG.entity ??= {}; core.CONFIG.entity.metadata ??= {}; core.CONFIG.viewer ??= {}; core.CONFIG.viewer.gallery ??= {}; core.CONFIG.viewer.scaleContainer ??= { x: 1, y: 1 }; this.isLightweight = Boolean(core.CONFIG.viewer.lightweight); setCore('isLightweight', this.isLightweight); this.EDITOR = Boolean(core.CONFIG.viewer.editor); setCore('EDITOR', this.EDITOR); const presentationModeFromConfig = this.parseBooleanParam(core.CONFIG.viewer.presentationMode); this.PRESENTATION_MODE = presentationModeFromConfig ?? Boolean(core.CONFIG.viewer.presentationMode); setCore('PRESENTATION_MODE', this.PRESENTATION_MODE); const sandboxModeFromConfig = this.parseBooleanParam(core.CONFIG.viewer.sandboxMode); this.SANDBOX_MODE = sandboxModeFromConfig ?? Boolean(core.CONFIG.viewer.sandboxMode); setCore('SANDBOX_MODE', this.SANDBOX_MODE); console.log(`Presentation mode: ${this.PRESENTATION_MODE ? "ON" : "OFF"}`); console.log(`Sandbox mode: ${this.SANDBOX_MODE ? "ON" : "OFF"}`); console.log(`AIM 3D-Viewer ${this.isLightweight ? '🪶 LIGHTWEIGHT' : '💪 FULL'} mode`); console.log(`Powered by Three.js (v${THREE.REVISION})`); if (!core.CONFIG.entity.metadata.sourceType) { core.CONFIG.entity.metadata.sourceType = SOURCE; console.log(`Metadata source: ${core.CONFIG.entity.metadata.sourceType}`); } this.container = document.getElementById(core.CONFIG.viewer.container); this.noticeContainer = document.createElement("div"); this.noticeContainer.id = "viewerNoticeContainer"; this.statusNotice = document.createElement("div"); this.statusNotice.id = "viewerStatusNotice"; this.statusNotice.className = "viewer-notice viewer-notice-status"; this.statusNotice.hidden = true; this.statusNotice.setAttribute("role", "status"); this.statusNotice.setAttribute("aria-live", "polite"); this.noticeContainer.appendChild(this.statusNotice); setCore("statusNotice", this.statusNotice); this.statusNoticeQueue = []; this.statusNoticeActive = false; if (this.statusNoticeTimer) { clearTimeout(this.statusNoticeTimer); this.statusNoticeTimer = null; } this.parseUrlOptions(); setCore('showNotifications', this.showNotifications); if (this.urlOptions.showNotifications !== undefined && this.urlOptions.showNotifications !== null) { core.showNotifications = this.urlOptions.showNotifications; } if (!this.container) { document.body.appendChild(this.noticeContainer); this.noticeContainer.style.bottom = "50%"; showToast("toasts.containerNotFound", "error", { duration: 5000 }); return; } setCore('container', this.container); document.body.classList.toggle("viewer-embed-page", this.isEmbedMode()); core.container?.appendChild(this.noticeContainer); setCore("noticeContainer", this.noticeContainer); console.log(`Presentation mode: ${core.PRESENTATION_MODE ? "ON" : "OFF"}`); console.log(`Sandbox mode: ${core.SANDBOX_MODE ? "ON" : "OFF"}`); this.currentLanguage = this.getStoredLanguage(); setCore('currentLanguage', this.currentLanguage); if (this.urlOptions.model) { this.container.setAttribute("3d", this.urlOptions.model); } if (this.shouldIgnoreLegacyEmbedDefaultModel()) { this.container.removeAttribute("3d"); } this.scrollTop = window.scrollY || document.documentElement.scrollTop; this.rect = core.container.getBoundingClientRect(); this.baseContainerRect = { width: this.rect.width, height: this.rect.height }; const e2eModel = this.getE2EModelOverride(); if (e2eModel) { core.container.setAttribute("3d", e2eModel); } this.fileObject.originalPath = this.normalizeFileUrl(core.container.getAttribute("3d")); setCore('fileObject', this.fileObject); if (this.urlOptions.scale !== undefined && this.urlOptions.scale !== null) { core.CONFIG.viewer.scaleContainer.x = this.urlOptions.scale.x; core.CONFIG.viewer.scaleContainer.y = this.urlOptions.scale.y; } core.CONFIG.viewer.canvasDimensions = { x: this.rect.width * Number(core.CONFIG.viewer.scaleContainer.x), y: this.rect.height * Number(core.CONFIG.viewer.scaleContainer.y), }; this.bottomLineGUI = core.CONFIG.viewer.canvasDimensions.y - 85; setCore('bottomLineGUI', this.bottomLineGUI); if (core.isLightweight) { core.CONFIG.viewer.lightweight = core.container.getAttribute("proxy"); } var elementsURL = window.location.pathname; elementsURL = elementsURL.match(core.CONFIG.entity.idUri); if (elementsURL !== null) { core.CONFIG.entity.id = elementsURL[1]; } else { if (this.urlOptions.id) { core.CONFIG.entity.id = this.urlOptions.id; } } if (core.CONFIG.entity.id) { core.container.setAttribute(core.CONFIG.entity.attributeId, core.CONFIG.entity.id); console.log("Entity ID:", core.CONFIG.entity.id); } // Initialize clipping planes at startup this.core = initClippingPlanes(); setCore('EXIT_CODE', this.EXIT_CODE); // Initialize objectsConfig in core setCore('objectsConfig', objectsConfig); setCore('outlineClipping', outlineClipping); core.objectsConfig.setupIndex = core.objectsConfig.index = 0; this.cameraTween = new TWEEN.Tween(); setCore('cameraTween', this.cameraTween); this.targetTween = new TWEEN.Tween(); setCore('targetTween', this.targetTween); core.container.classList.add("mainContainer"); if (core.container.hasAttribute("basePath")) { core.CONFIG.baseModulePath = core.container.getAttribute("basePath"); } this.setModelPaths(); core.CONFIG.viewer.exportPath = "/api/editor/xml-export/"; this.loadedFile = `${core.fileObject.basename}.${core.fileObject.extension}`; if (!core.PRESENTATION_MODE) { this.handHint = document.createElement("div"); this.handHint.id = "handHint"; this.handHint.hidden = true; core.container.appendChild(this.handHint); setCore('handHint', this.handHint); this.pickingHint = document.createElement("div"); this.pickingHint.id = "pickingHint"; this.pickingHint.className = "viewer-notice viewer-notice-hint"; this.pickingHint.textContent = "Shift + click to select multiple faces"; this.pickingHint.hidden = true; this.noticeContainer.appendChild(this.pickingHint); setCore("pickingHint", this.pickingHint); this.clippingHint = document.createElement("div"); this.clippingHint.id = "clippingHint"; this.clippingHint.className = "viewer-notice viewer-notice-hint"; this.clippingHint.textContent = "Drag active clipping plane helper to adjust cut"; this.clippingHint.hidden = true; this.noticeContainer.appendChild(this.clippingHint); setCore("clippingHint", this.clippingHint); core.guiContainer = document.createElement("div"); core.guiContainer.id = "guiContainer"; core.guiContainer.className = "guiContainer"; core.guiContainer.hidden = core.SANDBOX_MODE === true; core.container.appendChild(core.guiContainer); core.gui = new GUI({ container: core.guiContainer }); core.gui.domElement.style.visibility = "hidden"; this.metadataContainer = document.createElement("div"); this.metadataContainer.setAttribute("id", "metadata-container"); this.metadataContainer.style.top = -this.metadataContainer.getBoundingClientRect().top + "px"; if (this.urlOptions.hideMetadata) { this.metadataContainer.style.display = "none"; } setCore('metadataContainer', this.metadataContainer); setCore('colors', this.colors); setCore("planeHelpers", this.planeHelpers); setCore("planeParams", this.planeParams); setCore('materialProperties', this.materialProperties); setCore('materialsPropertiesText', this.materialsPropertiesText); setCore('intensity', this.intensity); setCore('environmentMapEnabled', this.environmentMapEnabled); setCore('environmentMapIntensity', this.environmentMapIntensity); setCore('environmentMapPreset', this.environmentMapPreset); this.clippingPlanes = this.core; setCore("clippingPlanes", this.clippingPlanes); setCore('helperObjects', this.helperObjects); setCore('lightHelper', this.lightHelper); setCore('selectedObjects', this.selectedObjects); core.showNotifications = true; } this.spinnerContainer = document.createElement("div"); this.spinnerContainer.id = "spinnerContainer"; this.spinnerElement = document.createElement("div"); this.spinnerElement.id = "spinner"; this.spinnerContainer.appendChild(this.spinnerElement); core.container.appendChild(this.spinnerContainer); this.circle = this.createModelLoadingProgress(this.spinnerElement); setCore('circle', this.circle); if (!core.PRESENTATION_MODE) { this.loadingLog = this.createLoadingLog(); setCore('loadingLog', this.loadingLog); } this.rect = core.container.getBoundingClientRect(); this.clock = new THREE.Timer(); Viewer.init(); if (!core.PRESENTATION_MODE) { Viewer.prepareStats(); } localStorage.setItem("viewerHintSeen", "0"); this.updateSize(); /*if (core.CONFIG.entity?.metadata?.source != null) { await Viewer.mainLoadModel(); }*/ Viewer.animate(); }, normalizeDrupalFilesPath(path) { if (!path || typeof path !== 'string') { return ''; } return path .replace(/^https?:\/{1,2}[^/]+\/?/, '') .replace(/^public:\/\//, '') .replace(/^\/?sites\/default\/files\/?/, '') .replace(/\/+/g, '/') .replace(/\/$/, ''); }, normalizeArchiveModelPath(path) { if (!path || typeof path !== "string") { return ""; } const injectGltfSegment = (pathname) => { if (!/\/[^/]+_(ZIP|RAR|TAR|XZ|GZ)\//i.test(pathname) || /\/gltf\//i.test(pathname)) { return pathname; } return pathname.replace( /^(.*\/[^/]+_(ZIP|RAR|TAR|XZ|GZ))(\/?)(.*)$/i, "$1/gltf/$4" ); }; if (/^[a-zA-Z][\w+-.]*:\/\//.test(path)) { try { const url = new URL(path); url.pathname = injectGltfSegment(url.pathname); return url.href; } catch (_err) { return injectGltfSegment(path); } } return injectGltfSegment(path); }, setModelPaths() { if (!core.fileObject.originalPath) { core.fileObject.filename = ""; core.fileObject.basename = ""; core.fileObject.extension = ""; core.fileObject.path = ""; core.fileObject.uri = ""; core.fileObject.relativePath = ""; return; } core.fileObject.filename = core.fileObject.originalPath.split("/").pop(); core.fileObject.basename = core.fileObject.filename.substring(0, core.fileObject.filename.lastIndexOf(".")); core.fileObject.extension = core.fileObject.filename.substring(core.fileObject.filename.lastIndexOf(".") + 1); core.fileObject.path = core.fileObject.originalPath.substring(0, core.fileObject.originalPath.lastIndexOf(core.fileObject.filename)) || "/"; core.fileObject.uri = core.fileObject.path.replace(core.CONFIG.mainUrl + "/", ""); core.fileObject.relativePath = Viewer.normalizeDrupalFilesPath(core.fileObject.uri); }, // Disable interaction hint on first interaction disableInteractionHint() { if (core.PRESENTATION_MODE) return; core.handHint.hidden = true; Viewer.stopGesture(); // Stop any running camera tweens when user interacts if (core.cameraTween && typeof core.cameraTween.stop === "function") { core.cameraTween.stop(); core.cameraTween = null; } if (core.targetTween && typeof core.targetTween.stop === "function") { core.targetTween.stop(); core.targetTween = null; } //core.handHint.classList.remove("hand-drag-animate"); localStorage.setItem("viewerHintSeen", "1"); }, addTextWatermark(_text, _scale) { var textGeo; var materials = [ new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, side: THREE.DoubleSide, depthTest: false, depthWrite: false, transparent: true, opacity: 0.4, }), // front new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, side: THREE.DoubleSide, depthTest: false, depthWrite: false, transparent: true, opacity: 0.4, }), // side ]; const loader = new FontLoader(); loader.load( `${core.DFG_ASSETS}/fonts/helvetiker_regular.typeface.json`, function (font) { const textGeo = new TextGeometry(_text, { font, size: _scale * 3, height: _scale / 10, curveSegments: 5, bevelEnabled: true, bevelThickness: _scale / 8, bevelSize: _scale / 10, bevelOffset: 0, bevelSegments: 1, }); textGeo.computeBoundingBox(); //const centerOffset = - 0.5 * (textGeo.boundingBox.max.x - textGeo.boundingBox.min.x); Viewer.textMesh = new THREE.Mesh(textGeo, materials); Viewer.textMesh.rotation.z = Math.PI; Viewer.textMesh.rotation.y = Math.PI; Viewer.textMesh.position.x = 0; Viewer.textMesh.position.y = 0; Viewer.textMesh.position.z = 0; Viewer.textMesh.renderOrder = 1; core.scene.add(Viewer.textMesh); } ); }, addTextPoint(_text, _scale, _point) { const loader = new FontLoader(); const bevelSize = _scale / 10; loader.load(`${core.DFG_ASSETS}/fonts/helvetiker_regular.typeface.json`, (font) => { const baseOptions = { font: font, size: _scale * 3, height: _scale, curveSegments: 4, bevelEnabled: true, bevelThickness: bevelSize, bevelSize: bevelSize / 10, bevelOffset: 0, bevelSegments: 1, depth: _scale / 10, }; const textGeo = new TextGeometry(_text, baseOptions); textGeo.computeBoundingBox(); const centerOffset = new THREE.Vector3(); textGeo.boundingBox.getCenter(centerOffset).negate(); textGeo.translate(centerOffset.x, centerOffset.y, centerOffset.z); const outlineGeo = textGeo.clone(); outlineGeo.scale(1.05, 1.08, 1.05); const outlineMat = new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.9, depthTest: false, depthWrite: false, }); const fillMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 1, depthTest: false, depthWrite: false, }); const outlineMesh = new THREE.Mesh(outlineGeo, outlineMat); outlineMesh.position.z = -_scale * 0.02; const fillMesh = new THREE.Mesh(textGeo, fillMat); const group = new THREE.Group(); group.add(outlineMesh); group.add(fillMesh); group.position.set(_point.x, _point.y, _point.z); group.renderOrder = 999; group.userData.isDistanceLabel = true; Viewer.rulerObject.add(group); }); }, selectObjectHierarchy(_id) { let search = true; for (let i = 0; i < core.selectedObjects.length && search === true; i++) { if (core.selectedObjects[i].id === _id) { search = false; if (core.selectedObjects[i].selected === true) { core.scene.getObjectById(_id).material = core.selectedObjects[i].originalMaterial; core.scene.getObjectById(_id).material.needsUpdate = true; core.selectedObjects[i].selected = false; core.selectedObjects.splice(core.selectedObjects.indexOf(core.selectedObjects[i]), 1); } } } if (search) { core.selectedObjects.push({ id: _id, selected: true, originalMaterial: core.scene.getObjectById(_id).material.clone(), }); const tempMaterial = core.scene.getObjectById(_id).material.clone(); const selectedColor = Viewer.toThreeColor("0x00FF00"); if (selectedColor) { tempMaterial.color = selectedColor; } core.scene.getObjectById(_id).material = tempMaterial; core.scene.getObjectById(_id).material.needsUpdate = true; } Viewer.updateHierarchySubmenuState(); }, recreateBoundingBox(object) { var _min = new THREE.Vector3(); var _max = new THREE.Vector3(); if (object instanceof THREE.Object3D) { object.traverse(function (mesh) { if (mesh instanceof THREE.Mesh) { mesh.geometry.computeBoundingBox(); var bBox = mesh.geometry.boundingBox; // compute overall bbox _min.x = Math.min(_min.x, bBox.min.x + mesh.position.x); _min.y = Math.min(_min.y, bBox.min.y + mesh.position.y); _min.z = Math.min(_min.z, bBox.min.z + mesh.position.z); _max.x = Math.max(_max.x, bBox.max.x + mesh.position.x); _max.y = Math.max(_max.y, bBox.max.y + mesh.position.y); _max.z = Math.max(_max.z, bBox.max.z + mesh.position.z); } }); var bBox_min = new THREE.Vector3(_min.x, _min.y, _min.z); var bBox_max = new THREE.Vector3(_max.x, _max.y, _max.z); var bBox_new = new THREE.Box3(bBox_min, bBox_max); object.position.set( (bBox_new.min.x + bBox_new.max.x) / 2, bBox_new.min.y, (bBox_new.min.z + bBox_new.max.z) / 2 ); } return object; }, normalizeFileUrl(rawUrl) { if (!rawUrl || typeof rawUrl !== "string") { return ""; } let url = rawUrl.trim(); if (url === "") { return ""; } if (/^\/[a-z][\w+.-]*:\/\//i.test(url)) { url = url.replace(/^\/+/, ""); } if (url.startsWith("public://")) { url = "/sites/default/files/" + url.substring("public://".length); } else if (url.startsWith("sites/default/files/")) { url = "/" + url; } const base = (core.CONFIG?.mainUrl || window.location.origin || "").replace(/\/+$/, ""); try { const parsed = new URL(url, window.location.origin); const host = (parsed.host || "").toLowerCase(); const path = parsed.pathname || ""; const hasBadHost = host === "default" || host === "dfg_3dviewer" || host.includes("_"); if (path.startsWith("/sites/default/files/")) { if (hasBadHost) { return `${base}${path}`; } if (parsed.protocol === "http:" || parsed.protocol === "https:") { return parsed.href; } return `${base}${path}`; } return parsed.href; } catch (_err) { if (url.startsWith("/sites/default/files/")) { return `${base}${url}`; } return url; } }, shouldIgnoreLegacyEmbedDefaultModel() { if (!this.isEmbedMode()) return false; if (this.urlOptions?.model || this.urlOptions?.id) return false; const sourceType = String(core.CONFIG?.entity?.metadata?.sourceType || "").toLowerCase(); if (!sourceType.startsWith("drupal")) return false; const currentModelAttr = String(this.container?.getAttribute("3d") || "").trim(); if (!currentModelAttr) return false; return /^(?:\.{1,2}\/)?examples\/box\.stl(?:\?.*)?$/i.test(currentModelAttr); }, buildGallery() { return buildThumbnailGallery(this); }, toHexColor(input) { if (!input) return null; // THREE.Color if (typeof input.getHex === "function") { return input.getHex(); } // hex number if (typeof input === "number") { return input >>> 0; } // hex string: "#ff00aa" / "ff00aa" if (typeof input === "string") { const s = input.replace("#", ""); if (/^[0-9a-fA-F]{6}$/.test(s)) return parseInt(s, 16); return null; } // array [r,g,b] / [r,g,b,a] if (Array.isArray(input)) { const [r, g, b] = input; if ([r, g, b].every(v => typeof v === "number")) { const rr = r <= 1 ? Math.round(r * 255) : r; const gg = g <= 1 ? Math.round(g * 255) : g; const bb = b <= 1 ? Math.round(b * 255) : b; return ((rr & 255) << 16) | ((gg & 255) << 8) | (bb & 255); } return null; } // object { r, g, b, a? } if (typeof input === "object" && "r" in input && "g" in input && "b" in input) { const rr = input.r <= 1 ? Math.round(input.r * 255) : input.r; const gg = input.g <= 1 ? Math.round(input.g * 255) : input.g; const bb = input.b <= 1 ? Math.round(input.b * 255) : input.b; return ((rr & 255) << 16) | ((gg & 255) << 8) | (bb & 255); } return null; }, toThreeColor(input) { const normalized = normalizeColor(input); if (!normalized) return null; return new THREE.Color( normalized.r / 255, normalized.g / 255, normalized.b / 255 ); }, /* picking and measurement moved to viewer/editor modules */ updateSize() { const isFullscreen = !!document.fullscreenElement; Viewer.FULLSCREEN = isFullscreen; if ( !Viewer.mainCanvas || !Viewer.fullscreenMode || !core.guiContainer ) { return; } let widthCSS; let heightCSS; let scale = {x: 1, y: 1}; const wrapper = Viewer.viewerWrapper || core.container; if (!wrapper) return; if (isFullscreen) { widthCSS = window.innerWidth; heightCSS = window.innerHeight; } else { scale = { x: Number( core.CONFIG.viewer.scaleContainer?.x || 1 ), y: Number( core.CONFIG.viewer.scaleContainer?.y || 1 ), }; const rect = wrapper.getBoundingClientRect(); widthCSS = rect.width || 800; heightCSS = rect.height || 600; } // final visual size const effectiveWidth = widthCSS * scale.x; const effectiveHeight = heightCSS * scale.y; // CSS size only Viewer.mainCanvas.style.width = `${effectiveWidth}px`; Viewer.mainCanvas.style.height = `${effectiveHeight}px`; const canvasRect = Viewer.mainCanvas.getBoundingClientRect(); const parentRect = core.container.getBoundingClientRect(); const bottom = parentRect.bottom - canvasRect.bottom + 12 || 24; if (isFullscreen) { Viewer.mainCanvas.style.width = "100vw"; Viewer.mainCanvas.style.height = "100vh"; core.editorToolbar.style.bottom = `${bottom}px`; } else { const extraHeight = effectiveHeight - heightCSS; if (core.editorToolbar) { core.editorToolbar.style.bottom = `${bottom}px`; } } // metadata overlay if (core.metadataContainer) { core.metadataContainer.style.width = "100%"; core.metadataContainer.style.height = "100%"; } // optional wrapper sync if ( Viewer.fileElement && Viewer.fileElement.length > 0 ) { Viewer.fileElement[0].style.height = `${effectiveHeight * 1.1}px`; } // GUI position if (!core.guiContainer.hidden) { const guiWidth = core.lilGui?.[0]?.getBoundingClientRect().width || core.guiContainer.getBoundingClientRect().width; if (guiWidth > 0) { core.guiContainer.style.left = `${effectiveWidth - guiWidth}px`; } } // camera if (core.camera.isOrthographicCamera) { this.updateOrthoFrustum( core.camera, effectiveWidth, effectiveHeight ); } else { core.camera.aspect = effectiveWidth / effectiveHeight; core.camera.updateProjectionMatrix(); } // renderer core.renderer.setPixelRatio( window.devicePixelRatio || 1 ); core.renderer.setSize( effectiveWidth, effectiveHeight, false ); // action menu if (Viewer.actionMenu) { if ( Viewer.actionMenu.classList.contains( "viewer-action-menu_in-toolbar" ) ) { Viewer.actionMenu.style.top = ""; Viewer.actionMenu.style.right = ""; Viewer.actionMenu.style.bottom = ""; } else { const menuMargin = 16; const toggleSize = Viewer.actionMenu .querySelector( ".viewer-action-menu_toggle" ) ?.getBoundingClientRect().height || 45; Viewer.actionMenu.style.top = `${effectiveHeight - toggleSize - menuMargin}px`; Viewer.actionMenu.style.right = `${menuMargin}px`; Viewer.actionMenu.style.bottom = "auto"; } } // hand hint if (core.handHint) { core.handHint.style.top = `${effectiveHeight - 150}px`; } core.controls?.update(); core.CONFIG.viewer.canvasDimensions = { x: effectiveWidth, y: effectiveHeight, }; }, async toggleFullscreen() { Viewer.closeActionMenu(); try { if (!document.fullscreenElement) { await core.container.requestFullscreen(); } else { await document.exitFullscreen(); } Viewer.updateSize(); Viewer.updateFullscreenButtonIcon(); Viewer.updateEditorToolbarLabels(); Viewer.updateEditorToolbarState(); } catch (err) { Viewer.reportError(err, { context: "Fullscreen error", toast: false, e2e: false, }); } }, onFullscreenChange () { // Layout (ESC + click) requestAnimationFrame(() => { requestAnimationFrame(() => { Viewer.updateSize(); Viewer.updateEditorToolbarLabels(); Viewer.updateEditorToolbarState(); }); }); Viewer.updateFullscreenButtonIcon(); Viewer.closeActionMenu(); }, exitFullscreenHandler() { var fullscreenElement = document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement; var fullscreenElement2 = document.webkitIsFullScreen && document.mozFullScreen && document.msFullscreenElement; if ( !fullscreenElement && typeof (fullscreenElement2 === undefined) && Viewer.FULLSCREEN ) { fullscreen(); } }, updateHandAnimation: (time) => { const g = core.GESTURE; if (!g) return; if (!g.active || !g.baseAngle || !g.target) return; const t = (time - g.startTime) / 1000; const s = Math.sin((t / core.GESTURE.period) * Math.PI * 2); // EASE-IN (smoothstep) const ei = Math.min(t / g.easeInTime, 1); const ease = ei * ei * (3 - 2 * ei); // smoothstep(0..1) // hand icon core.handHint?.style.setProperty( '--hand-x', `${s * core.GESTURE.handPx}px` ); // camera - orbit const sph = g.baseAngle.clone(); sph.theta = g.baseAngle.theta + s * core.GESTURE.orbitAngle * ease; core.camera.position .setFromSpherical(sph) .add(g.target); core.camera.lookAt(g.target); }, startGesture: (time) => { const g = core.GESTURE; if (!g) return; if (g.active) return; g.rotate = true; g.startTime = time; g.active = true; g.target = core.controls.target.clone(); g.baseAngle = new THREE.Spherical().setFromVector3( core.camera.position.clone().sub(g.target) ); core.controls.enabled = false; }, stopGesture: () => { if (!core.handHint) return; const g = core.GESTURE; if (!g) return; if (!g.active) return; g.rotate = false; g.active = false; core.controls.target.copy(g.target); core.controls.object.position.copy(core.camera.position); core.controls.update(); core.controls.enabled = true; g.baseAngle = null; g.target = null; core.handHint.hidden = true; core.GESTURE.active = false; }, animate: (time) => { requestAnimationFrame(Viewer.animate); const delta = Viewer.clock.getDelta(); if (!core.PRESENTATION_MODE) { // ========================= // GESTURE LIFECYCLE // ========================= const canGesture = !window.__E2E__ && !core.handHint?.hidden; if (canGesture && core.GESTURE?.rotate && !core.GESTURE?.active ) { Viewer.startGesture(time); } if (core.GESTURE?.active && (!core.GESTURE?.rotate || !canGesture)) { Viewer.stopGesture(); } // ========================= // GESTURE UPDATE // ========================= Viewer.updateHandAnimation(time); core.controls?.update(); if (Viewer.textMesh !== null) { Viewer.textMesh.lookAt(core.camera.position); } if (Viewer.ruler?.length) { Viewer.ruler.forEach((rulerObject) => { rulerObject?.traverse?.((child) => { if (child?.userData?.isDistanceLabel === true) { child.lookAt(core.camera.position); } }); }); } Viewer.updateAnnotationPOITooltipPosition(); } if (!core.GESTURE?.active || core.PRESENTATION_MODE) { core.controls?.update(); } if ((core.PRESENTATION_MODE ||core.handHint?.hidden) && !core.GESTURE?.active) { core.cameraTween?.update(time); core.targetTween?.update(time); } // ========================= // LOOP UPDATE // ========================= if (Viewer.mixer) { Viewer.mixer.update(delta); } core.renderer?.clear(); core.renderer?.render(core.scene, core.camera); core.stats?.update(); }, onDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }, showSandboxGuiAfterModelLoad() { if (!core.guiContainer) return; core.guiContainer.hidden = false; core.lilGui = document.getElementsByClassName("lil-gui root"); const updateAfterLayout = () => { Viewer.updateSize(); requestAnimationFrame(() => Viewer.updateSize()); }; requestAnimationFrame(updateAfterLayout); }, async onDrop(e) { e.preventDefault(); const files = e.dataTransfer.files; if (!files || files.length === 0) { return; } const file = files[0]; const extension = file.name .split('.') .pop() .toLowerCase(); if (core.SUPPORTED_EXTENSIONS.includes(extension)) { await loadDroppedModel(file); return; } if (Viewer.SUPPORTED_ARCHIVES.includes(extension)) { await loadDroppedArchive(file); return; } toastHelper("unsupportedFormat", "error"); }, async changeScale() { if (core.transformControl.getMode() === "scale") { switch (core.transformControl.axis) { case "X": case "XY": core.helperObjects[0].scale.set( core.helperObjects[0].scale.x, core.helperObjects[0].scale.x, core.helperObjects[0].scale.x ); break; case "Y": case "YZ": core.helperObjects[0].scale.set( core.helperObjects[0].scale.y, core.helperObjects[0].scale.y, core.helperObjects[0].scale.y ); break; case "Z": case "XZ": core.helperObjects[0].scale.set( core.helperObjects[0].scale.x, core.helperObjects[0].scale.x, core.helperObjects[0].scale.x ); break; } } }, syncOutlineClippingTransform() { const outline = core.outlineClipping; const object = core.helperObjects?.[0]; if (!outline || !object) return; outline.position.copy(object.position); outline.quaternion.copy(object.quaternion); outline.scale.copy(object.scale); outline.updateMatrixWorld(true); }, async calculateObjectScale() { if (core.renderer) { core.renderer.localClippingEnabled = true; } Viewer.syncOutlineClippingTransform(); const boundingBox = new THREE.Box3(); if (Array.isArray(core.helperObjects[0])) { for (let i = 0; i < core.helperObjects[0].length; i++) { const box = new THREE.Box3().setFromObject(core.helperObjects[0][i], true); boundingBox.union(box); } } else { boundingBox.setFromObject(core.helperObjects[0], true); } if (boundingBox.isEmpty()) return; const center = new THREE.Vector3(); boundingBox.getCenter(center); const _distance = new THREE.Vector3( Math.max(Math.abs(boundingBox.max.x), Math.abs(boundingBox.min.x)), Math.max(Math.abs(boundingBox.max.y), Math.abs(boundingBox.min.y)), Math.max(Math.abs(boundingBox.max.z), Math.abs(boundingBox.min.z)) ); Viewer.distanceGeometry = _distance; setCore("distanceGeometry", Viewer.distanceGeometry); if (core.clippingPlanes?.length >= 3) { core.clippingPlanes[0].constant = _distance.x; core.clippingPlanes[1].constant = _distance.y; core.clippingPlanes[2].constant = _distance.z; } Viewer.planeParams.planeX.constantX = _distance.x; Viewer.planeParams.planeY.constantY = _distance.y; Viewer.planeParams.planeZ.constantZ = _distance.z; if (core.clippingFolder?.controllers?.[1]) { core.clippingFolder.controllers[1]._max = _distance.x; core.clippingFolder.controllers[1]._min = -_distance.x; core.clippingFolder.controllers[1].setValue(_distance.x); core.clippingFolder.controllers[1].updateDisplay(); } if (core.clippingFolder?.controllers?.[3]) { core.clippingFolder.controllers[3]._max = _distance.y; core.clippingFolder.controllers[3]._min = -_distance.y; core.clippingFolder.controllers[3].setValue(_distance.y); core.clippingFolder.controllers[3].updateDisplay(); } if (core.clippingFolder?.controllers?.[5]) { core.clippingFolder.controllers[5]._max = _distance.z; core.clippingFolder.controllers[5]._min = -_distance.z; core.clippingFolder.controllers[5].setValue(_distance.z); core.clippingFolder.controllers[5].updateDisplay(); } if (Viewer.planeHelpers?.length >= 3 && core.clippingPlanes?.length >= 3) { for (let i = 0; i < 3; i++) { const helper = Viewer.planeHelpers[i]; const plane = core.clippingPlanes[i]; if (!helper || !plane) continue; helper.position.copy(plane.normal).multiplyScalar(-plane.constant); if (i === 0 || i === 2) { helper.userData.clippingCenterY = center.y; helper.updateMatrixWorld(true); } } } var _maxDistance = Math.max(_distance.x, _distance.y, _distance.z); Viewer.planeHelpers?.forEach(h => h && (h.size = _maxDistance)); }, changeLightRotation() { core.lightHelper.update(); }, takeScreenshot() { return captureAndUploadThumbnail(this); }, async mainLoadModelWrapper() { if (core.autoPath !== '') { core.autoPath = Viewer.normalizeFileUrl(core.autoPath); core.autoPath = Viewer.normalizeArchiveModelPath(core.autoPath); core.fileObject.filename = core.autoPath.split('/').pop(); core.fileObject.basename = core.fileObject.filename.substring( 0, core.fileObject.filename.lastIndexOf('.') ); core.fileObject.extension = core.fileObject.filename.substring( core.fileObject.filename.lastIndexOf('.') + 1 ); Viewer._ext = core.fileObject.extension.toLowerCase(); core.fileObject.path = core.autoPath.substring(0, core.autoPath.lastIndexOf(core.fileObject.filename)); } await Viewer.mainLoadModel(); Viewer.applyPendingAnnotationsIfAny(); }, async mainLoadModel() { console.log("Loading model:", core.fileObject.basename, ", with extension:", core.fileObject.extension); if (Viewer._ext === "glb" || Viewer._ext === "gltf") { await loadModel(); } else if (Viewer.SUPPORTED_ARCHIVES.includes(Viewer._ext)) { core.loadedFile = "_" + Viewer._ext.toUpperCase() + "/"; core.fileObject.path = core.fileObject.path + core.fileObject.basename + core.loadedFile core.fileObject.extension = "glb"; core.fileObject.newExtension = Viewer._ext; await loadModel(); } else { //core.fileObject.extension = "glb"; if (Viewer._ext === "glb") { await loadModel(); } else await loadModel(); } this.applyCameraOverridesFromUrl(); }, prepareSandboxScene() { if (core.mainObject?.length) { core.mainObject.forEach((obj) => Viewer.removeAndDisposeFromScene(obj)); core.mainObject.length = 0; } if (core.loadingLog) { core.loadingLog.finish?.(); } if (core.circle) { core.circle.set?.(0, 100); core.circle.complete(); } if (core.mainCanvas && core.CONFIG?.viewer?.background) { core.mainCanvas.style.setProperty("background", core.CONFIG.viewer.background); } core.camera?.position.set(0, 60, 180); core.controls?.target.set(0, 0, 0); core.controls?.update(); this.applyCameraOverridesFromUrl(); if (window.viewer) { window.viewer.modelLoaded = false; } toastHelper("sandboxDropModel", "info", { formats: this.getSupportedFormatsText(), archives: this.getSupportedArchiveFormatsText(), detailI18nKey: "toasts.supportedFormats", key: "sandbox-drop-model", replace: true, persistent: true, variant: "sandbox", }); }, applyCameraOverridesFromUrl() { if (!core.camera) return; const cameraPosition = this.urlOptions?.cameraPosition; const cameraTarget = this.urlOptions?.cameraTarget; const cameraFov = this.urlOptions?.cameraFov; const hasPosition = cameraPosition && Number.isFinite(cameraPosition.x) && Number.isFinite(cameraPosition.y) && Number.isFinite(cameraPosition.z); const hasTarget = cameraTarget && Number.isFinite(cameraTarget.x) && Number.isFinite(cameraTarget.y) && Number.isFinite(cameraTarget.z); const hasFov = Number.isFinite(cameraFov); if (!hasPosition && !hasTarget && !hasFov) return; if (hasPosition) { core.camera.position.copy(cameraPosition); core.cameraLight?.position.copy(cameraPosition); } if (hasTarget) { core.controls?.target.copy(cameraTarget); core.camera.lookAt(cameraTarget); } if (hasFov) { const normalizedFov = Math.min(179, Math.max(1, Number(cameraFov))); core.camera.fov = normalizedFov; if (this.embedConfigInputs?.fov && this.embedConfigInputs.fov.value === "") { this.embedConfigInputs.fov.value = String(normalizedFov); } } core.camera.updateProjectionMatrix(); core.controls?.update(); }, createClippingPlaneAxis(_number, axis = "z") { var tempClippingControl = new TransformControls(core.camera, core.renderer.domElement); tempClippingControl.space = "world"; tempClippingControl.setMode("translate"); tempClippingControl.showX = axis === "x"; tempClippingControl.showY = axis === "y"; tempClippingControl.showZ = axis === "z"; tempClippingControl.addEventListener("change", Viewer.render); tempClippingControl.addEventListener("objectChange", function (event) { if (event.target === undefined || event.target.object === undefined) { return; } let newConstant; switch (_number) { case 0: newConstant = event.target.worldPositionStart.x + event.target.pointEnd.x; core.clippingPlanes[_number].constant = newConstant; core.planeParams.planeX.constantX = newConstant; if (core.clippingFolder.controllers[1]) { core.clippingFolder.controllers[1].setValue(newConstant); } core.planeHelpers[0].position.copy(core.clippingPlanes[0].normal).multiplyScalar(-newConstant); break; case 1: newConstant = event.target.worldPositionStart.y + event.target.pointEnd.y; core.clippingPlanes[_number].constant = newConstant; core.planeParams.planeY.constantY = newConstant; if (core.clippingFolder.controllers[3]) { core.clippingFolder.controllers[3].setValue(newConstant); } core.planeHelpers[1].position.copy(core.clippingPlanes[1].normal).multiplyScalar(-newConstant); break; case 2: newConstant = event.target.worldPositionStart.z + event.target.pointEnd.z; core.clippingPlanes[_number].constant = newConstant; core.planeParams.planeZ.constantZ = newConstant; if (core.clippingFolder.controllers[5]) { core.clippingFolder.controllers[5].setValue(newConstant); } core.planeHelpers[2].position.copy(core.clippingPlanes[2].normal).multiplyScalar(-newConstant); break; } }); tempClippingControl.addEventListener("dragging-changed", function (event) { core.controls.enabled = !event.value; }); return tempClippingControl; }, resetCamera() { const targetCamera = core.cameraCoords || core.camera.position.clone(); const targetControls = core.controlsTarget || core.controls?.target?.clone() || new THREE.Vector3(); if (!targetCamera || typeof targetCamera.x !== 'number') { return; } const startCam = core.camera.position.clone(); const startTarget = core.controls?.target?.clone() || new THREE.Vector3(); //const targetDistance = startTarget.distanceTo(targetControls); const startDir = startTarget.clone().sub(startCam).normalize(); const endDir = targetControls.clone().sub(targetCamera).normalize(); const angle = startDir.angleTo(endDir); const rotationFactor = 2.0; const rotationDistance = angle * rotationFactor; const linearDistance = Math.max( startCam.distanceTo(targetCamera), startTarget.distanceTo(targetControls) ); const distance = Math.max(linearDistance, rotationDistance); const speed = 1.25; const duration = THREE.MathUtils.clamp((distance / speed) * 1000, 300, 3000); core.cameraTween = new TWEEN.Tween(startCam) .to(targetCamera, duration) .easing(TWEEN.Easing.Cubic.Out) .onUpdate(() => { core.camera.position.copy(startCam); core.cameraLight.position.copy(startCam); core.camera.updateProjectionMatrix(); }); core.targetTween = new TWEEN.Tween(startTarget) .to(targetControls, duration) .easing(TWEEN.Easing.Cubic.Out) .onUpdate(() => { core.controls?.target.copy(startTarget); core.controls?.update(); }); core.cameraTween.onComplete(() => { core.camera.position.copy(targetCamera); core.cameraLight.position.copy(targetCamera); core.controls?.target.copy(targetControls); core.controls?.update(); core.camera.updateProjectionMatrix(); }); core.cameraTween.start(); core.targetTween.start(); }, buildMetadata(rotateMetadata) { return buildEditorMetadata(this, rotateMetadata); }, prepareStats () { // stats core.stats = new Stats(); core.stats.domElement.classList.add("viewer-stats"); if (typeof core.guiContainer !== "undefined" && core.stats?.dom) { core.guiContainer.appendChild(core.stats.dom); core.stats.dom.style.left = (core.guiContainer.getBoundingClientRect().width - core.stats.domElement.getBoundingClientRect().width + 10) + 'px'; core.stats.dom.style.visibility = 'hidden'; } Viewer.windowHalfX = core.CONFIG.viewer.canvasDimensions.x / 2; Viewer.windowHalfY = core.CONFIG.viewer.canvasDimensions.y / 2; Viewer.editorFolder = core.gui.addFolder(t("gui.editor", "Editor")).close(); Viewer.editorFolder.domElement?.classList.add("viewer-gui-main-folder"); Viewer.editorFolder.domElement?.setAttribute("data-gui-main-folder", "editor"); core.i18nGui.editorFolder = Viewer.editorFolder; const showTransformHintToast = (mode) => { const hints = { translate: t("toasts.transformMove", "Move: drag axis arrows to reposition the object."), rotate: t("toasts.transformRotate", "Rotate: drag rotation rings to rotate the object."), scale: t("toasts.transformScale", "Scale: drag axis handles to resize the object."), }; const message = hints[mode]; if (!message) return; showToast(message, { duration: 5200 }); }; const showTransformLightHintToast = (mode) => { const hints = { translate: t("toasts.transformLightMove", "Transform Light - Move: drag axis arrows to move the directional light."), rotate: t("toasts.transformLightTarget", "Transform Light - Target: drag axis arrows to reposition the light target."), }; const message = hints[mode]; if (!message) return; showToast(message, { duration: 5200 }); }; core.i18nGui.transformObjectController = Viewer.editorFolder .add(Viewer.transformText, "Transform 3D Object", { [t("gui.none", "None")]: "", [t("gui.move", "Move")]: "translate", [t("gui.rotate", "Rotate")]: "rotate", [t("gui.scale", "Scale")]: "scale", }) .name(t("gui.transform3dObject", "Transform 3D Object")) .onChange(function (value) { if (value === "") { core.transformControl.detach(); core.axesHelper.visible = false; core.renderer.localClippingEnabled = true; } else { const object = core.helperObjects?.[0]; if (!object) { return; } core.axesHelper.visible = true; core.renderer.localClippingEnabled = true; core.transformControl.setMode(value); core.transformControl.attach(object); showTransformHintToast(value); } Viewer.updateEditorToolbarState(); }); core.i18nGui.transformModeController = Viewer.editorFolder .add(Viewer.transformText, "Transform Mode", { [t("gui.local", "Local")]: "local", [t("gui.global", "Global")]: "global", }) .name(t("gui.transformMode", "Transform Mode")) .onChange(function (value) { core.transformControl.space = value; Viewer.updateEditorToolbarState(); }); const lightFolder = Viewer.editorFolder.addFolder(t("gui.directionalLight", "Directional Light")).close(); core.i18nGui.lightFolder = lightFolder; core.i18nGui.transformLightController = lightFolder .add(Viewer.transformText, "Transform Light", { [t("gui.none", "None")]: "", [t("gui.move", "Move")]: "translate", [t("gui.target", "Target")]: "rotate", }) .name(t("gui.transformLight", "Transform Light")) .onChange(function (value) { if (value === "") { core.transformControlLight.detach(); core.transformControlLightTarget.detach(); core.lightHelper.visible = false; } else { core.lightHelper.visible = true; if (value === "translate") { core.transformControlLight.setMode("translate"); core.transformControlLight.attach(core.dirLight); core.transformControlLightTarget.detach(); showTransformLightHintToast("translate"); } else { core.transformControlLightTarget.setMode("translate"); core.transformControlLightTarget.attach(core.dirLightTarget); core.transformControlLight.detach(); showTransformLightHintToast("rotate"); } } Viewer.updateEditorToolbarState(); }); core.i18nGui.directionalLightColorController = lightFolder .addColor(Viewer.colors, "DirectionalLight") .name(t("gui.color", "Color")) .onChange(function (value) { core.lightObjects[0].color = new THREE.Color(value); }) .listen(); core.i18nGui.directionalLightIntensityController = lightFolder .add(Viewer.intensity, "startIntensityDir", 0, 10) .name(t("gui.intensity", "Intensity")) .onChange(function (value) { core.lightObjects[0].intensity = value; }) .listen(); const lightFolderAmbient = Viewer.editorFolder.addFolder(t("gui.ambientLight", "Ambient Light")).close(); core.i18nGui.lightFolderAmbient = lightFolderAmbient; core.i18nGui.ambientLightColorController = lightFolderAmbient .addColor(Viewer.colors, "AmbientLight") .name(t("gui.color", "Color")) .onChange(function (value) { Viewer.ambientLight.color = new THREE.Color(value); }) .listen(); core.i18nGui.ambientLightIntensityController = lightFolderAmbient .add(Viewer.intensity, "startIntensityAmbient", 0, 10) .name(t("gui.intensity", "Intensity")) .onChange(function (value) { Viewer.ambientLight.intensity = value; }) .listen(); const lightFolderCamera = Viewer.editorFolder.addFolder(t("gui.cameraLight", "Camera Light")).close(); core.i18nGui.lightFolderCamera = lightFolderCamera; core.i18nGui.cameraLightColorController = lightFolderCamera .addColor(Viewer.colors, "CameraLight") .name(t("gui.color", "Color")) .onChange(function (value) { Viewer.cameraLight.color = new THREE.Color(value); }) .listen(); core.i18nGui.cameraLightIntensityController = lightFolderCamera .add(Viewer.intensity, "startIntensityCamera", 0, 10) .name(t("gui.intensity", "Intensity")) .onChange(function (value) { Viewer.cameraLight.intensity = value; }) .listen(); const backgroundFolder = Viewer.editorFolder.addFolder(t("gui.backgroundColor", "Background Color")).close(); core.i18nGui.backgroundFolder = backgroundFolder; core.i18nGui.backgroundColorController = backgroundFolder .addColor(Viewer.colors, "BackgroundColor") .name(t("gui.backgroundColor", "Background Color")) .onChange(function (value) { changeBackground( Viewer.backgroundType["Background Type"], value, Viewer.colors["BackgroundColorOuter"] ); }) .listen(); core.i18nGui.backgroundColorOuterController = backgroundFolder .addColor(Viewer.colors, "BackgroundColorOuter") .name(t("gui.backgroundColorOuter", "Background Color Outer")) .onChange(function (value) { changeBackground( Viewer.backgroundType["Background Type"], Viewer.colors["BackgroundColor"], value ); }) .listen(); core.i18nGui.backgroundTypeController = backgroundFolder .add(Viewer.backgroundType, "Background Type", { [t("gui.linear", "Linear")]: "linear", [t("gui.gradient", "Gradient")]: "gradient", }) .name(t("gui.backgroundType", "Background Type")) .onChange(function (value) { if (value == "linear") Viewer.backgroundOuterFolder.hide(); else Viewer.backgroundOuterFolder.show(); changeBackground( value, Viewer.colors["BackgroundColor"], Viewer.colors["BackgroundColorOuter"] ); }); setCore("clippingFolder", Viewer.clippingFolder); if (core.EDITOR) { core.clippingFolder = Viewer.editorFolder.addFolder(t("gui.clippingFolder", "Clipping Planes")).close(); core.i18nGui.clippingFolder = core.clippingFolder; core.materialsFolder = Viewer.editorFolder.addFolder(t("gui.materials", "Materials")).close(); core.i18nGui.materialsFolder = core.materialsFolder; setCore("materialsFolder", core.materialsFolder); Viewer.pickingModeController = Viewer.editorFolder.add( { togglePickingMode() { Viewer.togglePickingMode(); }, }, "togglePickingMode" ); Viewer.updatePickingModeControllerLabel(); Viewer.clearSelectedFacesController = Viewer.editorFolder.add( { [t("gui.clearSelectedFaces", "Clear selected faces")]() { Viewer.clearSelectedFaces(); Viewer.restoreLastPickedFace(); }, }, t("gui.clearSelectedFaces", "Clear selected faces") ); core.i18nGui.clearSelectedFacesController = Viewer.clearSelectedFacesController; Viewer.selectedFacesCountController = Viewer.editorFolder .add(Viewer.pickingStats, "Selected faces") .listen(); Viewer.updateSelectedFacesControllerLabel(); Viewer.selectedFacesCountController.disable(); //Viewer.metadataFolder = core.gui.addFolder(t("gui.metadata", "Metadata")).close(); //Viewer.metadataFolder.domElement?.classList.add("viewer-gui-main-folder"); //Viewer.metadataFolder.domElement?.setAttribute("data-gui-main-folder", "metadata"); //core.i18nGui.metadataFolder = Viewer.metadataFolder; Viewer.updatePickingControlsVisibility(); Viewer.distanceMeasurementController = Viewer.editorFolder.add( { toggleDistanceMeasurement() { Viewer.toggleDistanceMeasurement(); }, }, "toggleDistanceMeasurement" ); Viewer.updateDistanceMeasurementControllerLabel(); core.i18nGui.resetCameraController = Viewer.editorFolder.add( { [t("gui.resetCameraPosition", "Reset camera position")]() { Viewer.resetCamera(); }, }, t("gui.resetCameraPosition", "Reset camera position") ); } if (!core.isLightweight) { Viewer.propertiesFolder = Viewer.editorFolder.addFolder(t("gui.saveProperties", "Save properties")).close(); core.i18nGui.propertiesFolder = Viewer.propertiesFolder; core.i18nGui.savePropPositionController = Viewer.propertiesFolder.add(Viewer.saveProperties, "Position").name(t("gui.position", "Position")); core.i18nGui.savePropRotationController = Viewer.propertiesFolder.add(Viewer.saveProperties, "Rotation").name(t("gui.rotation", "Rotation")); core.i18nGui.savePropScaleController = Viewer.propertiesFolder.add(Viewer.saveProperties, "Scale").name(t("gui.scale", "Scale")); core.i18nGui.savePropCameraController = Viewer.propertiesFolder.add(Viewer.saveProperties, "Camera").name(t("gui.camera", "Camera")); core.i18nGui.savePropDirectionalController = Viewer.propertiesFolder.add(Viewer.saveProperties, "DirectionalLight").name(t("gui.directionalLight", "Directional Light")); core.i18nGui.savePropAmbientController = Viewer.propertiesFolder.add(Viewer.saveProperties, "AmbientLight").name(t("gui.ambientLight", "Ambient Light")); core.i18nGui.savePropCameraLightController = Viewer.propertiesFolder.add(Viewer.saveProperties, "CameraLight").name(t("gui.cameraLight", "Camera Light")); core.i18nGui.savePropBackgroundController = Viewer.propertiesFolder.add(Viewer.saveProperties, "BackgroundColor").name(t("gui.backgroundColor", "Background Color")); } if (core.EDITOR && !core.isLightweight) { core.i18nGui.saveController = Viewer.editorFolder.add( { [t("gui.save", "Save")]() { Viewer.saveEditorMetadata(); } }, t("gui.save", "Save") ); core.i18nGui.renderPreviewController = Viewer.editorFolder.add( { [t("gui.renderPreview", "Render preview")]() { Viewer.takeScreenshot(); }, }, t("gui.renderPreview", "Render preview") ); } if (core.EDITOR) { Viewer.createEditorToolbar(); Viewer.setEditorAdvancedPanelVisible(false); } Viewer.updateLocalizedUI(); }, async startModelProcessing() { /*const r = await fetch("/api/model/create", {method:"POST" }); const data = await r.json(); const id = data.entity_id;*/ const _id = core.CONFIG.entity.id; localStorage.setItem("processing_model_id", _id); let loadingMap = this.getProcessingLoadingSteps(); loadingMap = core.isLocalPreview ? loadingMap.slice(-2) : loadingMap; UltraLoader.start(loadingMap); setCore("UltraLoader", UltraLoader); const poller = new StatusPoller(_id); setCore("poller", poller); poller.start(); }, // IIIF setup and loading async setupManifesto(newUrlOrJson, type="url", manifestType = "iiif") { if (type === "text") { Viewer.iiifConfigURL.url = ""; } else { Viewer.iiifConfigURL.url = newUrlOrJson; } const loadedManifest = manifestType === "iiif" ? await loadIIIFManifest(newUrlOrJson) : await loadAIM3IFManifest(newUrlOrJson); if (loadedManifest.modelUrls.length === 0) { // no 3D model found, use example model loadedManifest.modelUrls.push('https://raw.githubusercontent.com/IIIF/3d/main/assets/astronaut/astronaut.glb'); showToast(t("toasts.noIiiifModelFallback", "No 3D model found in IIIF manifest, loading example model.")); } // reset scene and release GPU resources from the previous model batch Viewer.resetLoadedModelState(); core.axesHelper.visible = false; console.log("TOTAL Annotations: " + loadedManifest.annotations.length); if (loadedManifest.annotations.length !== loadedManifest.modelUrls.length) { //console.warn("Number of annotations does not match number of model URLs, adding testing model..."); const diff = loadedManifest.annotations.length - loadedManifest.modelUrls.length; if (diff > 0) { // Need more model URLs → push empty strings (or null) for (let i = 0; i < diff; i++) { loadedManifest.modelUrls.push(Viewer.testModelURL); core.objectsConfig.models.push({name: "Test Model", url: Viewer.testModelURL}); } } } for (const [i, url] of loadedManifest.modelUrls?.entries()) { core.objectsConfig.index = i; core.fileObject.originalPath = loadedManifest.modelUrl = url; //fileObject.originalPath = loadedManifest.modelUrl; Viewer.setModelPaths(); manifestType === "iiif" ? await getAnnotations(loadedManifest, core.objectsConfig) : await applyManifestConfig(loadedManifest, core.objectsConfig); if (loadedManifest.scenes && loadedManifest.scenes.length > 0) { core.objectsConfig.scenes = loadedManifest.scenes; } Viewer._ext = core.fileObject.extension.toLowerCase(); await Viewer.mainLoadModel(); } }, async loadManifestoURL(type = "iiif") { // Load IIIF URL const className = type === "iiif" ? "IIIF" : "AIM3IF"; const titleKey = type === "iiif" ? "iiif" : "aim3if"; const form = document.getElementById("form-manifesto"); const collapseBtn = document.getElementById("manifesto-toggle-collapse"); form?.setAttribute("data-viewer-theme", Viewer.currentTheme); Viewer.updateIIIFFormLabels(); Viewer.bindEventListener(collapseBtn, "click", () => { form.classList.toggle("collapsed"); collapseBtn.textContent = form.classList.contains("collapsed") ? "▸" : "▾"; collapseBtn.title = form.classList.contains("collapsed") ? t("${titleKey}.expand", "Expand") : t("${titleKey}.collapse", "Collapse"); }); // create a small dropdown to switch iiif manifests at runtime Viewer.bindEventListener(document.getElementById("manifesto-manifest-select"), "change", async (ev) => { try { if (ev.target.value !== Viewer.iiifConfigURL.url) { core.objectsConfig.setupIndex = 0; await Viewer.setupManifesto(ev.target.value, "url", type); } } catch (err) { Viewer.reportError(err, { context: "Error loading ${className} manifest", }); } }); Viewer.bindEventListener(document.getElementById("load-manifesto-from-url"), "click", async (ev) => { try { const inputElement = document.getElementById("manifesto-manifest-url"); if (inputElement.value === "" || !Viewer.isUrlFlexible(inputElement.value)) { inputElement.style.border = "2px solid red"; showToast("manifesto.invalidUrl", "warning"); return; } else { inputElement.style.border = "2px solid green"; core.objectsConfig.setupIndex = 0; console.log("Loading ${className} manifest from URL: " + inputElement.value); await Viewer.setupManifesto(inputElement.value, "url", type); } } catch (err) { Viewer.reportError(err, { context: "Error loading ${className} manifest", }); } }); Viewer.bindEventListener(document.getElementById("load-manifesto-from-text"), "click", async (ev) => { try { const inputElement = document.getElementById("manifesto-manifest-text"); if (inputElement.value === "" || !Viewer.isValidJsonObject(inputElement.value)) { inputElement.style.border = "2px solid red"; showToast("manifesto.invalidJson", "warning"); return; } else { inputElement.style.border = "2px solid green"; core.objectsConfig.setupIndex = 0; console.log("Loading ${className} manifest from privided text"); if (type === "iiif") { await Viewer.setupManifesto(inputElement.value, "text", type); } else { await Viewer.setupManifesto(inputElement.value, "text", type); } } } catch (err) { Viewer.reportError(err, { context: "Error loading ${className} manifest", }); } }); }, isUrlFlexible(string) { try { new URL(string); return true; } catch { return /^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/\S*)?$/i.test(string); } }, isValidJsonObject(text) { try { const parsed = JSON.parse(text); return typeof parsed === 'object' && parsed !== null; } catch { return false; } }, async init() { if (!Viewer.renderer) { Viewer.camera = new THREE.PerspectiveCamera( 45, core.CONFIG.viewer.canvasDimensions.x / core.CONFIG.viewer.canvasDimensions.y, 0.001, 999000000 ); Viewer.camera.position.set(0, 0, 0); setCore('renderer', Viewer.renderer); setCore('camera', Viewer.camera); setCore('mainObject', Viewer.mainObject); Viewer.scene = new THREE.Scene(); setCore('scene', Viewer.scene); setCore('activeScene', Viewer.activeScene); const hostname = window.location.hostname; const isLocal = ['localhost', '127.0.0.1', '::1'].includes(window.location.hostname); const isLocalNetwork = hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.endsWith('.local'); const isCodeSandbox = hostname.includes('codesandbox.io') || hostname.includes('csb.app'); this.isLocalPreview = isLocal || isLocalNetwork || isCodeSandbox; setCore('isLocalPreview', this.isLocalPreview); console.info('Running on', window.location.hostname, '- Local preview mode:', core.isLocalPreview); if (!core.PRESENTATION_MODE && !core.SANDBOX_MODE) { Viewer.startModelProcessing(); } const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444); hemiLight.position.set(0, 200, 0); core.scene.add(hemiLight); Viewer.ambientLight = new THREE.AmbientLight(0x404040); // soft white light core.scene.add(Viewer.ambientLight); setCore('ambientLight', Viewer.ambientLight); Viewer.dirLight = new THREE.DirectionalLight(0xffffff); Viewer.dirLight.position.set(0, 100, 50); Viewer.dirLight.castShadow = true; Viewer.dirLight.shadow.camera.top = 180; Viewer.dirLight.shadow.camera.bottom = -100; Viewer.dirLight.shadow.camera.left = -120; Viewer.dirLight.shadow.camera.right = 120; Viewer.dirLight.shadow.bias = -0.0001; Viewer.dirLight.shadow.mapSize.width = 1024 * 4; Viewer.dirLight.shadow.mapSize.height = 1024 * 4; core.scene.add(Viewer.dirLight); Viewer.lightObjects.push(Viewer.dirLight); setCore('dirLight', Viewer.dirLight); setCore('lightObjects', Viewer.lightObjects); Viewer.cameraLightTarget = new THREE.Object3D(); Viewer.cameraLightTarget.position.set( Viewer.camera.position.x, Viewer.camera.position.y, Viewer.camera.position.z ); core.scene.add(Viewer.cameraLightTarget); // Store in core setCore('cameraLightTarget', Viewer.cameraLightTarget); Viewer.cameraLight = new THREE.DirectionalLight(0xffffff); Viewer.cameraLight.position.set(core.camera.position); Viewer.cameraLight.castShadow = false; Viewer.cameraLight.intensity = 0.3; core.scene.add(Viewer.cameraLight); Viewer.cameraLight.target = Viewer.cameraLightTarget; Viewer.cameraLight.target.updateMatrixWorld(); // Store in core setCore('cameraLight', Viewer.cameraLight); core.renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true, colorManagement: true, sortObjects: true, preserveDrawingBuffer: true, powerPreference: "high-performance", alpha: true, shadowMap: { enabled: true, type: THREE.PCFSoftShadowMap }, localClippingEnabled: true, physicallyCorrectLights: true, autoClear: false, setClearColor: (0x000000, 0.0), outputColorSpace: THREE.SRGBColorSpace, toneMapping: THREE.ACESFilmicToneMapping, toneMappingExposure: 0.65 }); core.renderer.localClippingEnabled = true; setCore('renderer', core.renderer); core.renderer.domElement.id = "MainCanvas"; Viewer.mainCanvas = document.getElementById("MainCanvas") || core.renderer.domElement; if (window.__E2E__) { document.body.appendChild(core.renderer.domElement); } core.renderer.domElement.tabIndex = 0; core.renderer.domElement.setAttribute("aria-label", "3D viewer canvas"); if (!core.PRESENTATION_MODE) { Viewer.bindEventListener(core.renderer.domElement, "pointerdown", Viewer.onPointerDown); Viewer.bindEventListener(core.renderer.domElement, "pointerup", Viewer.onPointerUp); Viewer.bindEventListener(core.renderer.domElement, "pointermove", Viewer.onPointerMove); Viewer.bindEventListener(core.renderer.domElement, "mouseenter", (event) => { if (!Viewer.isPointerDirectlyOverCanvas(event)) return; Viewer.maybeShowKeyboardHint(); }); Viewer.lastWindowFocusAt = Date.now(); Viewer.bindEventListener(window, "focus", () => { Viewer.lastWindowFocusAt = Date.now(); }); Viewer.bindEventListener(document, "visibilitychange", () => { if (document.visibilityState === "visible") { Viewer.lastWindowFocusAt = Date.now(); } }); Viewer.bindEventListener(core.renderer.domElement, "pointerdown", () => { core.renderer.domElement.focus(); }); Viewer.bindEventListener(core.renderer.domElement, "keydown", Viewer.onViewerKeyDown); if (core.isLocalPreview || core.SANDBOX_MODE) { Viewer.bindEventListener(core.renderer.domElement, "dragover", Viewer.onDragOver); Viewer.bindEventListener(core.renderer.domElement, "drop", Viewer.onDrop); } } const devicePixelRatio = window.devicePixelRatio || 1; core.renderer.setSize(core.CONFIG.viewer.canvasDimensions.x, core.CONFIG.viewer.canvasDimensions.y); if (isE2E) { console.info('E2E MODE ENABLED'); core.renderer.setPixelRatio(1); core.renderer.toneMappingExposure = 1; if (typeof disablePostProcessing === 'function') { disablePostProcessing(); } this.ensureE2EState(); } else { core.renderer.setPixelRatio(devicePixelRatio); } core.renderer.domElement.style.width = core.CONFIG.viewer.canvasDimensions.x + "px"; core.renderer.domElement.style.height = core.CONFIG.viewer.canvasDimensions.y + "px"; core.renderer.domElement.style.display = "block"; core.container.appendChild(core.renderer.domElement); Viewer.mainCanvas.classList.add("mainCanvas"); Viewer.viewerWrapper = core.container.closest('.viewer-wrapper'); if (!Viewer.viewerWrapper) { Viewer.viewerWrapper = core.container.parentElement; Viewer.viewerWrapper.classList.add('viewer-wrapper'); } Viewer.attachEditorToolbar(); core.camera.aspect = core.CONFIG.viewer.canvasDimensions.x / core.CONFIG.viewer.canvasDimensions.y; core.camera.updateProjectionMatrix(); setCore('mainCanvas', Viewer.mainCanvas); if (!core.PRESENTATION_MODE) { getModuleAssetBasePath(); Viewer.actionMenu = document.createElement("div"); Viewer.actionMenu.setAttribute("id", "viewerActionMenu"); Viewer.actionMenu.innerHTML = `
`; core.container.appendChild(Viewer.actionMenu); Viewer.actionMenuToggle = Viewer.actionMenu.querySelector("#viewerActionMenuToggle"); Viewer.actionMenuPanel = Viewer.actionMenu.querySelector(".viewer-action-menu_panel"); Viewer.applyTheme(Viewer.getStoredTheme(), { persist: false }); Viewer.viewEntity = document.createElement("button"); Viewer.viewEntity.setAttribute("id", "viewEntity"); Viewer.viewEntity.setAttribute("type", "button"); Viewer.viewEntity.hidden = true; Viewer.downloadModel = document.createElement("a"); setCore('downloadModel', Viewer.downloadModel); core.downloadModel.setAttribute("id", "downloadModel"); core.downloadModel.hidden = true; Viewer.fullscreenMode = document.createElement("button"); Viewer.updateFullscreenButtonIcon(); Viewer.themeMode = document.createElement("button"); Viewer.themeMode.setAttribute("id", "viewerThemeMode"); Viewer.themeMode.setAttribute("type", "button"); // Create language mode container and dropdown Viewer.languageModeContainer = document.createElement("div"); Viewer.languageModeContainer.setAttribute("id", "viewerLanguageModeContainer"); Viewer.languageModeContainer.className = "language-mode-container"; Viewer.languageMode = document.createElement("button"); Viewer.languageMode.setAttribute("id", "viewerLanguageMode"); Viewer.languageMode.setAttribute("type", "button"); Viewer.languageModeDropdown = document.createElement("div"); Viewer.languageModeDropdown.setAttribute("id", "viewerLanguageModeDropdown"); Viewer.languageModeDropdown.className = "language-mode-dropdown"; Viewer.languageModeDropdown.hidden = true; const languages = [ { code: "en", label: "EN", class: "language-dropdown-item-english" }, { code: "pl", label: "PL", class: "language-dropdown-item-polish" }, { code: "de", label: "DE", class: "language-dropdown-item-german" } ]; languages.forEach(lang => { const item = document.createElement("div"); item.className = `language-dropdown-item ${lang.class}`; item.dataset.lang = lang.code; item.textContent = lang.label; item.setAttribute("role", "option"); item.setAttribute("aria-selected", core.currentLanguage === lang.code ? "true" : "false"); Viewer.bindEventListener(item, "click", () => Viewer.selectLanguage(lang.code)); Viewer.languageModeDropdown.appendChild(item); }); Viewer.languageModeContainer.appendChild(Viewer.languageMode); Viewer.languageModeContainer.appendChild(Viewer.languageModeDropdown); Viewer.updateThemeControlLabels(); Viewer.updateLanguageControlLabels(); Viewer.actionMenuPanel.appendChild(Viewer.languageModeContainer); Viewer.actionMenuPanel.appendChild(Viewer.themeMode); Viewer.actionMenuPanel.appendChild(Viewer.viewEntity); Viewer.actionMenuPanel.appendChild(Viewer.downloadModel); if (Viewer.urlOptions.hideUi) { Viewer.actionMenu.hidden = true; } this.createEmbedConfiguratorPanel(); setCore('viewEntity', Viewer.viewEntity); Viewer.bindEventListener(Viewer.languageMode, "click", Viewer.toggleLanguage.bind(Viewer)); Viewer.bindEventListener(Viewer.themeMode, "click", Viewer.toggleTheme.bind(Viewer)); Viewer.bindEventListener(Viewer.viewEntity, "click", Viewer.openEmbedConfiguratorFromMenu.bind(Viewer)); Viewer.updateEmbedMenuEntryState(); Viewer.applyLanguage({ persist: false }); Viewer.bindEventListener(Viewer.downloadModel, "click", () => Viewer.closeActionMenu()); Viewer.bindEventListener(document, "click", (event) => { if ( !Viewer.actionMenu?.contains(event.target) && !Viewer.embedConfiguratorPanel?.contains(event.target) ) { Viewer.closeActionMenu(); } }); Viewer.handHint.innerHTML = `Hand hint`; Viewer.rect = core.container.getBoundingClientRect(); if (Viewer.viewerWrapper === core.container && core.CONFIG.viewer?.scaleContainer) { const scale = { x: Number(core.CONFIG.viewer.scaleContainer.x || 1), y: Number(core.CONFIG.viewer.scaleContainer.y || 1), }; Viewer.baseContainerRect = { width: Viewer.rect.width / scale.x, height: Viewer.rect.height / scale.y, }; } core.guiContainer.style.maxHeight = `${Viewer.rect.height - 20}px`; //core.lilGui = document.getElementsByClassName("lil-gui root"); Viewer.fileElement = document.getElementsByClassName("field--type-file"); if (Viewer.fileElement.length > 0) { Viewer.fileElement[0].style.height = core.CONFIG.viewer.canvasDimensions.y * 1.1 + "px"; } if (core.CONFIG.viewer.gallery?.build === true && !core.SANDBOX_MODE) { Viewer.buildGallery(); } } Viewer.controls = new OrbitControls(core.camera, core.renderer.domElement); Viewer.controls.target.set(0, 100, 0); Viewer.controls.enableDamping = true; Viewer.controls.dampingFactor = 0.05; Viewer.controls.enableRotate = true; if (core.PRESENTATION_MODE) { //TODO Viewer.controls.autoRotate = true; Viewer.controls.autoRotateSpeed = 1.5; // in seconds document.body.classList.add("presentation-mode"); document.documentElement.classList.add("presentation-mode"); core.renderer.setClearColor(0x000000, 0); } if (typeof Viewer.urlOptions.autoRotate === "boolean") { Viewer.controls.autoRotate = Viewer.urlOptions.autoRotate; } if (Number.isFinite(Viewer.urlOptions.autoRotateSpeed)) { Viewer.controls.autoRotateSpeed = Viewer.urlOptions.autoRotateSpeed; } if (Viewer.urlOptions.disableInteraction || core.PRESENTATION_MODE) { Viewer.controls.enabled = false; Viewer.controls.enableRotate = false; Viewer.controls.enablePan = false; Viewer.controls.enableZoom = false; } Viewer.controls.update(); setCore('controls', Viewer.controls); setCore('GESTURE', Viewer.GESTURE); setCore('lastTime', Viewer.lastTime); setCore('helperObjects', Viewer.helperObjects); if (!core.PRESENTATION_MODE) { Viewer.transformControl = new TransformControls(core.camera, core.renderer.domElement); Viewer.transformControl.rotationSnap = THREE.MathUtils.degToRad(5); Viewer.transformControl.space = "local"; Viewer.transformControl.addEventListener("change", Viewer.render); Viewer.transformControl.addEventListener("objectChange", () => { Viewer.changeScale(); Viewer.syncOutlineClippingTransform(); }); Viewer.transformControl.addEventListener("mouseUp", () => { Viewer.syncOutlineClippingTransform(); Viewer.calculateObjectScale(); }); Viewer.transformControl.addEventListener("dragging-changed", function (event) { core.controls.enabled = !event.value; }); core.scene.add(Viewer.transformControl.getHelper()); setCore('transformControl', Viewer.transformControl); Viewer.transformControlLight = new TransformControls(core.camera, core.renderer.domElement); Viewer.transformControlLight.space = "local"; Viewer.transformControlLight.addEventListener("change", Viewer.render); //Viewer.transformControlLight.addEventListener('objectChange', changeLightRotation); Viewer.transformControlLight.addEventListener( "dragging-changed", function (event) { core.controls.enabled = !event.value; } ); core.scene.add(Viewer.transformControlLight.getHelper()); setCore('transformControlLight', Viewer.transformControlLight); Viewer.transformControlLightTarget = new TransformControls( core.camera, core.renderer.domElement ); Viewer.transformControlLightTarget.space = "global"; Viewer.transformControlLightTarget.addEventListener("change", Viewer.render); Viewer.transformControlLightTarget.addEventListener( "objectChange", Viewer.changeLightRotation ); Viewer.transformControlLightTarget.addEventListener( "dragging-changed", function (event) { core.controls.enabled = !event.value; } ); core.scene.add(Viewer.transformControlLightTarget.getHelper()); setCore('transformControlLightTarget', Viewer.transformControlLightTarget); Viewer.transformControlClippingPlaneX = Viewer.createClippingPlaneAxis(0, "x"); Viewer.transformControlClippingPlaneY = Viewer.createClippingPlaneAxis(1, "y"); Viewer.transformControlClippingPlaneZ = Viewer.createClippingPlaneAxis(2, "z"); setCore('transformControlClippingPlaneX', Viewer.transformControlClippingPlaneX); setCore('transformControlClippingPlaneY', Viewer.transformControlClippingPlaneY); setCore('transformControlClippingPlaneZ', Viewer.transformControlClippingPlaneZ); setCore('clippingPlanes', Viewer.clippingPlanes); setCore('selectObjectHierarchy', Viewer.selectObjectHierarchy); } Viewer.GESTURE.handPx *= Math.min(window.innerWidth / 1200, 1); Viewer._ext = core.fileObject.extension.toLowerCase(); if (Viewer.SUPPORTED_ARCHIVES.includes(Viewer._ext)) { Viewer.archiveType = Viewer._ext; } core.autoPath = ""; if (core.isLocalPreview && !core.PRESENTATION_MODE && !core.SANDBOX_MODE) { const picker = document.getElementById('example-model-picker'); const selectModel = document.getElementById('example-model-select'); const themeToggle = document.getElementById('example-theme-toggle'); const viewerElement = document.getElementById('DFG_3DViewer'); if (picker && selectModel && viewerElement) { Viewer.updateLocalPreviewLabels(); const localurl = new URL(window.location.href); let selectedModel = localurl.searchParams.get('model'); if (!selectedModel) { selectedModel = localStorage.getItem('dfg3dviewer-example-model'); } if (!selectedModel) { selectedModel = viewerElement.getAttribute('3d'); } if (!selectedModel) { selectedModel = './examples/box.stl'; } core.autoPath = selectedModel; picker.style.display = 'inline-flex'; selectModel.value = selectedModel; viewerElement.setAttribute('3d', selectedModel); if (themeToggle) { themeToggle.hidden = true; } selectModel.addEventListener('change', () => { core.autoPath = selectModel.value; window.localStorage.setItem('dfg3dviewer-example-model', selectModel.value); this.resetLoadedModelState(); this.mainLoadModelWrapper(); }); } } if (core.SANDBOX_MODE) { Viewer.prepareSandboxScene(); } else if (!core.PRESENTATION_MODE) { const sourceType = core.CONFIG.entity.metadata.sourceType.toLowerCase(); console.log("Loading from source: " + sourceType); if (window.__E2E__) { try { await Viewer.mainLoadModelWrapper(); } catch (error) { Viewer.reportError(error, { context: "E2E model load error", }); } } else if (sourceType === "drupal") { try { if (core.CONFIG.entity.metadata.exportUrl && core.CONFIG.entity.metadata.exportUrl !== "") { const response = await fetch(core.CONFIG.viewer.exportPath + core.CONFIG.entity.id, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/xml' }, body: JSON.stringify({ id: core.CONFIG.entity.id, domain: core.CONFIG.metadataUrl }) }); if (!response.ok) { throw new Error(`XML export failed: ${response.status}`); } const xmlText = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(xmlText, 'application/xml'); Viewer.pendingAnnotationsXml = Viewer.extractAnnotationsXmlFromExportDocument(doc); core.autoPath = ''; const nodes = doc.getElementsByTagName('*'); for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if ( node.tagName?.includes('converted_file') && node.textContent ) { core.autoPath = node.textContent; break; } } } await Viewer.mainLoadModelWrapper(); } catch (err) { Viewer.reportError(err, { context: core.isLightweight ? "Lightweight model load error" : "Metadata load error", }); } } else if (sourceType === "iiif") { Viewer.cleanupTransientUI(); createManifestUI("iiif"); console.log("Loading from source: " + core.CONFIG.entity.metadata.sourceType); if (Viewer.iiifConfigURL.url !== "") { createIIIFDropdown(Viewer.iiifConfigURL); await Viewer.loadManifestoURL(); core.CONFIG.entity.metadata.sourceType = "IIIF"; await Viewer.setupManifesto(Viewer.iiifConfigURL.url); } } else if (sourceType === "aim3if") { Viewer.cleanupTransientUI(); createManifestUI("aim3if"); console.log("Loading from source: " + core.CONFIG.entity.metadata.sourceType); if (core.CONFIG.entity.metadata.url) { createAIM3IFDropdown(core.CONFIG.entity.metadata.url); await Viewer.loadManifestoURL("aim3if"); core.CONFIG.entity.metadata.sourceType = "AIM3IF"; await Viewer.setupManifesto(core.CONFIG.entity.metadata.url, "url", "aim3if"); } } else { console.log("Custom metadata source:" + core.CONFIG.entity.metadata.sourceType); try { switch(core.CONFIG.entity.metadata.sourceType.substring(0, 6).toLowerCase()) { case "drupal": console.log("Loading from URL: " + core.CONFIG.entity.metadata.url); break; case "file": //TODO: add more sources break; } // Load model for custom metadata sources await Viewer.mainLoadModelWrapper(); } catch (error) { Viewer.reportError(error, { context: core.isLightweight ? "Lightweight model load error" : "Custom metadata load error", }); } } } else { await Viewer.mainLoadModelWrapper(); } core.renderer.setPixelRatio(devicePixelRatio); const update = () => Viewer.updateSize(); Viewer.bindEventListener(window, 'resize', update); Viewer.resizeObserver = new ResizeObserver(update); Viewer.resizeObserver.observe(Viewer.viewerWrapper); Viewer.bindEventListener(document, 'fullscreenchange', Viewer.onFullscreenChange); const onOrientationChange = () => setTimeout(update, 100); Viewer.bindEventListener(window, 'orientationchange', onOrientationChange); } }, render() { core.controls?.update(); core.renderer?.render(core.scene, core.camera); } }; attachLocalizationTheme(Viewer); attachLoadingStatus(Viewer); attachMaterialsEditor(Viewer); attachAnnotations(Viewer); attachPicking(Viewer); attachMeasurement(Viewer); attachEmbedConfigurator(Viewer); export async function expectWebGL(page, showToast) { await expect .poll(async () => { return page.evaluate(() => { const canvas = document.querySelector('canvas'); if (!canvas) return false; return !!( canvas.getContext('webgl2') || canvas.getContext('webgl') ); }); }, { timeout: 5000, message: 'WebGL context not available', }) .toBeTruthy(); } window.Viewer = Viewer; (async () => { try { await Viewer.MainInit(); } catch (error) { Viewer.renderFatalError(error); } })();