Spaces:
Running
Running
| (async function() { | |
| // Retrieve the current script tag and load the JSON configuration file from the data-config attribute. | |
| const scriptTag = document.currentScript; | |
| const configUrl = scriptTag.getAttribute("data-config"); | |
| let config = {}; | |
| if (configUrl) { | |
| try { | |
| const response = await fetch(configUrl); | |
| config = await response.json(); | |
| } catch (error) { | |
| console.error("Error loading config file:", error); | |
| return; | |
| } | |
| } else { | |
| console.error("No config file provided. Please set a data-config attribute on the script tag."); | |
| return; | |
| } | |
| // Load the external CSS file if provided in the config. | |
| if (config.css_url) { | |
| const linkEl = document.createElement("link"); | |
| linkEl.rel = "stylesheet"; | |
| linkEl.href = config.css_url; | |
| document.head.appendChild(linkEl); | |
| } | |
| // Generate a unique identifier for this widget instance. | |
| const instanceId = Math.random().toString(36).substr(2, 8); | |
| // Read configuration values from the JSON file. | |
| const gifUrl = config.gif_url; | |
| const plyUrl = config.ply_url; | |
| // Camera constraint parameters | |
| const minZoom = parseFloat(config.minZoom || "0"); | |
| const maxZoom = parseFloat(config.maxZoom || "20"); | |
| const minAngle = parseFloat(config.minAngle || "0"); | |
| const maxAngle = parseFloat(config.maxAngle || "360"); | |
| const minAzimuth = config.minAzimuth !== undefined ? parseFloat(config.minAzimuth) : -Infinity; | |
| const maxAzimuth = config.maxAzimuth !== undefined ? parseFloat(config.maxAzimuth) : Infinity; | |
| // Read initial orbit parameters for desktop. | |
| const initAlphaDesktop = config.initAlpha !== undefined ? parseFloat(config.initAlpha) : 0.5; | |
| const initBetaDesktop = config.initBeta !== undefined ? parseFloat(config.initBeta) : 0.5; | |
| const initRadiusDesktop = config.initRadius !== undefined ? parseFloat(config.initRadius) : 5; | |
| // Read initial orbit parameters for phone. | |
| const initAlphaPhone = config.initAlphaPhone !== undefined ? parseFloat(config.initAlphaPhone) : initAlphaDesktop; | |
| const initBetaPhone = config.initBetaPhone !== undefined ? parseFloat(config.initBetaPhone) : initBetaDesktop; | |
| const initRadiusPhone = config.initRadiusPhone !== undefined ? parseFloat(config.initRadiusPhone) : initRadiusDesktop; | |
| // Detect if the device is iOS. | |
| const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; | |
| // Also detect Android devices. | |
| const isMobile = isIOS || /Android/i.test(navigator.userAgent); | |
| // Choose the appropriate initial orbit values based on device type. | |
| const chosenInitAlpha = isMobile ? initAlphaPhone : initAlphaDesktop; | |
| const chosenInitBeta = isMobile ? initBetaPhone : initBetaDesktop; | |
| const chosenInitRadius = isMobile ? initRadiusPhone : initRadiusDesktop; | |
| // Determine the aspect ratio. | |
| let aspectPercent = "100%"; | |
| if (config.aspect) { | |
| if (config.aspect.indexOf(":") !== -1) { | |
| const parts = config.aspect.split(":"); | |
| const w = parseFloat(parts[0]); | |
| const h = parseFloat(parts[1]); | |
| if (!isNaN(w) && !isNaN(h) && w > 0) { | |
| aspectPercent = (h / w * 100) + "%"; | |
| } | |
| } else { | |
| const aspectValue = parseFloat(config.aspect); | |
| if (!isNaN(aspectValue) && aspectValue > 0) { | |
| aspectPercent = (100 / aspectValue) + "%"; | |
| } | |
| } | |
| } else { | |
| const parentContainer = scriptTag.parentNode; | |
| const containerWidth = parentContainer.offsetWidth; | |
| const containerHeight = parentContainer.offsetHeight; | |
| if (containerWidth > 0 && containerHeight > 0) { | |
| aspectPercent = (containerHeight / containerWidth * 100) + "%"; | |
| } | |
| } | |
| // Create the widget container. | |
| const widgetContainer = document.createElement('div'); | |
| widgetContainer.id = 'ply-widget-container-' + instanceId; | |
| widgetContainer.classList.add('ply-widget-container'); | |
| // Add a mobile class if on a phone. | |
| if (isMobile) { | |
| widgetContainer.classList.add('mobile'); | |
| } | |
| // Set inline style for aspect ratio. | |
| widgetContainer.style.height = "0"; | |
| widgetContainer.style.paddingBottom = aspectPercent; | |
| widgetContainer.innerHTML = ` | |
| <!-- GIF Preview Container --> | |
| <div id="gif-preview-container-${instanceId}" class="gif-preview-container"> | |
| <img id="preview-image-${instanceId}" alt="Preview" crossorigin="anonymous"> | |
| </div> | |
| <!-- Viewer Container --> | |
| <div id="viewer-container-${instanceId}" class="viewer-container" style="display: none;"> | |
| <canvas id="canvas-${instanceId}" class="ply-canvas"></canvas> | |
| <div id="progress-dialog-${instanceId}" class="progress-dialog"> | |
| <progress id="progress-indicator-${instanceId}" max="100" value="0"></progress> | |
| </div> | |
| <button id="close-btn-${instanceId}" class="widget-button close-btn">X</button> | |
| <button id="fullscreen-toggle-${instanceId}" class="widget-button fullscreen-toggle">⇱</button> | |
| <button id="help-toggle-${instanceId}" class="widget-button help-toggle">?</button> | |
| <button id="reset-camera-btn-${instanceId}" class="widget-button reset-camera-btn"> | |
| <span class="reset-icon">⟲</span> | |
| </button> | |
| <div id="menu-content-${instanceId}" class="menu-content"></div> | |
| </div> | |
| `; | |
| scriptTag.parentNode.appendChild(widgetContainer); | |
| // Grab element references. | |
| const gifPreview = document.getElementById('gif-preview-container-' + instanceId); | |
| const viewerContainer = document.getElementById('viewer-container-' + instanceId); | |
| const previewImage = document.getElementById('preview-image-' + instanceId); | |
| const closeBtn = document.getElementById('close-btn-' + instanceId); | |
| const fullscreenToggle = document.getElementById('fullscreen-toggle-' + instanceId); | |
| const helpToggle = document.getElementById('help-toggle-' + instanceId); | |
| const resetCameraBtn = document.getElementById('reset-camera-btn-' + instanceId); | |
| const menuContent = document.getElementById('menu-content-' + instanceId); | |
| const canvas = document.getElementById('canvas-' + instanceId); | |
| const progressDialog = document.getElementById('progress-dialog-' + instanceId); | |
| const progressIndicator = document.getElementById('progress-indicator-' + instanceId); | |
| // Set help instructions based on device type. | |
| if (isMobile) { | |
| menuContent.innerHTML = ` | |
| - Pour vous déplacer, glissez deux doigts sur l'écran.<br> | |
| - Pour orbiter, utilisez un doigt.<br> | |
| - Pour zoomer, pincez avec deux doigts. | |
| `; | |
| } else { | |
| menuContent.innerHTML = ` | |
| - orbitez avec le clic droit<br> | |
| - zoomez avec la molette<br> | |
| - déplacez vous avec le clic gauche | |
| `; | |
| } | |
| // Global variables to track state | |
| let SPLAT = null; | |
| let cameraInstance = null; | |
| let controlsInstance = null; | |
| let rendererInstance = null; | |
| let sceneInstance = null; | |
| let animFrameId = null; | |
| let isViewerInitialized = false; | |
| let resizeHandler = null; | |
| // If a gif_url is provided, set the preview image. | |
| // Otherwise, hide the preview container, show the viewer immediately, | |
| // and hide the "close" button since there's no preview to return to. | |
| if (gifUrl) { | |
| previewImage.src = gifUrl; | |
| gifPreview.style.display = 'block'; | |
| viewerContainer.style.display = 'none'; | |
| } else { | |
| gifPreview.style.display = 'none'; | |
| viewerContainer.style.display = 'block'; | |
| closeBtn.style.display = 'none'; | |
| setTimeout(() => { | |
| initializeViewer(); | |
| }, 100); | |
| } | |
| // --- Button Event Handlers --- | |
| if (gifUrl) { | |
| // When GIF is clicked, hide it and initialize the viewer | |
| gifPreview.addEventListener('click', function() { | |
| console.log("GIF preview clicked, showing 3D viewer"); | |
| gifPreview.style.display = 'none'; | |
| viewerContainer.style.display = 'block'; | |
| initializeViewer(); | |
| }); | |
| } | |
| // Close button handler - hide viewer and show GIF | |
| closeBtn.addEventListener('click', function() { | |
| console.log("Close button clicked"); | |
| // Handle fullscreen exit | |
| if (document.fullscreenElement === widgetContainer) { | |
| if (document.exitFullscreen) { | |
| document.exitFullscreen(); | |
| } | |
| } | |
| if (widgetContainer.classList.contains('fake-fullscreen')) { | |
| widgetContainer.classList.remove('fake-fullscreen'); | |
| fullscreenToggle.textContent = '⇱'; | |
| } | |
| // Clean up the viewer resources | |
| cleanupViewer(); | |
| // Hide viewer and show GIF | |
| viewerContainer.style.display = 'none'; | |
| gifPreview.style.display = 'block'; | |
| }); | |
| // Fullscreen toggle handler | |
| fullscreenToggle.addEventListener('click', function() { | |
| if (isIOS) { | |
| // iOS doesn't support proper fullscreen, so we use CSS | |
| if (!widgetContainer.classList.contains('fake-fullscreen')) { | |
| widgetContainer.classList.add('fake-fullscreen'); | |
| } else { | |
| widgetContainer.classList.remove('fake-fullscreen'); | |
| resetCamera(); | |
| } | |
| fullscreenToggle.textContent = widgetContainer.classList.contains('fake-fullscreen') ? '⇲' : '⇱'; | |
| } else { | |
| // Standard fullscreen API for other browsers | |
| if (!document.fullscreenElement) { | |
| if (widgetContainer.requestFullscreen) { | |
| widgetContainer.requestFullscreen(); | |
| } else if (widgetContainer.webkitRequestFullscreen) { | |
| widgetContainer.webkitRequestFullscreen(); | |
| } else if (widgetContainer.mozRequestFullScreen) { | |
| widgetContainer.mozRequestFullScreen(); | |
| } else if (widgetContainer.msRequestFullscreen) { | |
| widgetContainer.msRequestFullscreen(); | |
| } | |
| } else { | |
| if (document.exitFullscreen) { | |
| document.exitFullscreen(); | |
| } | |
| } | |
| } | |
| }); | |
| // Listen for native fullscreen changes. | |
| document.addEventListener('fullscreenchange', function() { | |
| if (document.fullscreenElement === widgetContainer) { | |
| fullscreenToggle.textContent = '⇲'; | |
| widgetContainer.style.height = '100%'; | |
| widgetContainer.style.paddingBottom = '0'; | |
| resetCamera(); | |
| } else { | |
| fullscreenToggle.textContent = '⇱'; | |
| widgetContainer.style.height = '0'; | |
| widgetContainer.style.paddingBottom = aspectPercent; | |
| resetCamera(); | |
| } | |
| }); | |
| // Help toggle button | |
| helpToggle.addEventListener('click', function(e) { | |
| e.stopPropagation(); | |
| menuContent.style.display = (menuContent.style.display === 'block') ? 'none' : 'block'; | |
| }); | |
| // Reset camera button | |
| resetCameraBtn.addEventListener('click', function() { | |
| console.log("Reset camera button clicked"); | |
| resetCamera(); | |
| }); | |
| // Handle Escape key for fullscreen exit | |
| document.addEventListener('keydown', function(e) { | |
| if (e.key === 'Escape' || e.key === 'Esc') { | |
| let wasFullscreen = false; | |
| if (document.fullscreenElement === widgetContainer) { | |
| wasFullscreen = true; | |
| if (document.exitFullscreen) { | |
| document.exitFullscreen(); | |
| } | |
| } | |
| if (widgetContainer.classList.contains('fake-fullscreen')) { | |
| wasFullscreen = true; | |
| widgetContainer.classList.remove('fake-fullscreen'); | |
| fullscreenToggle.textContent = '⇱'; | |
| } | |
| if (wasFullscreen) { | |
| resetCamera(); | |
| } | |
| } | |
| }); | |
| // --- Camera Reset Function --- | |
| function resetCamera() { | |
| console.log("Resetting camera to initial position"); | |
| if (!SPLAT || !cameraInstance || !controlsInstance || !sceneInstance) { | |
| console.log("Cannot reset camera - SPLAT not loaded or camera/controls not initialized"); | |
| return; | |
| } | |
| try { | |
| // Dispose the current controls | |
| if (controlsInstance && typeof controlsInstance.dispose === 'function') { | |
| controlsInstance.dispose(); | |
| } | |
| // Reset camera to default position | |
| cameraInstance = new SPLAT.Camera(); | |
| // Create new controls | |
| controlsInstance = new SPLAT.OrbitControls( | |
| cameraInstance, | |
| canvas, | |
| 0.5, | |
| 0.5, | |
| 5, | |
| true, | |
| new SPLAT.Vector3(), | |
| chosenInitAlpha, | |
| chosenInitBeta, | |
| chosenInitRadius | |
| ); | |
| // Set control constraints | |
| controlsInstance.maxZoom = maxZoom; | |
| controlsInstance.minZoom = minZoom; | |
| controlsInstance.minAngle = minAngle; | |
| controlsInstance.maxAngle = maxAngle; | |
| controlsInstance.minAzimuth = minAzimuth; | |
| controlsInstance.maxAzimuth = maxAzimuth; | |
| controlsInstance.panSpeed = isMobile ? 0.5 : 1.2; | |
| // Update controls | |
| controlsInstance.update(); | |
| console.log("Camera reset complete"); | |
| } catch (error) { | |
| console.error("Error resetting camera:", error); | |
| } | |
| } | |
| // Clean up all viewer resources | |
| function cleanupViewer() { | |
| console.log("Cleaning up viewer resources..."); | |
| // Stop animation frame | |
| if (animFrameId) { | |
| cancelAnimationFrame(animFrameId); | |
| animFrameId = null; | |
| } | |
| // Remove resize handler | |
| if (resizeHandler) { | |
| window.removeEventListener('resize', resizeHandler); | |
| resizeHandler = null; | |
| } | |
| // Dispose controls | |
| if (controlsInstance && typeof controlsInstance.dispose === 'function') { | |
| try { | |
| controlsInstance.dispose(); | |
| } catch (e) { | |
| console.warn("Error disposing controls:", e); | |
| } | |
| controlsInstance = null; | |
| } | |
| // Dispose renderer | |
| if (rendererInstance && typeof rendererInstance.dispose === 'function') { | |
| try { | |
| rendererInstance.dispose(); | |
| } catch (e) { | |
| console.warn("Error disposing renderer:", e); | |
| } | |
| rendererInstance = null; | |
| } | |
| // Clear scene | |
| sceneInstance = null; | |
| // Clear camera | |
| cameraInstance = null; | |
| // Reset WebGL context | |
| if (canvas) { | |
| const ctx = canvas.getContext('webgl') || canvas.getContext('webgl2'); | |
| if (ctx && ctx.getExtension('WEBGL_lose_context')) { | |
| try { | |
| ctx.getExtension('WEBGL_lose_context').loseContext(); | |
| } catch (e) { | |
| console.warn("Error releasing WebGL context:", e); | |
| } | |
| } | |
| } | |
| // Mark viewer as not initialized | |
| isViewerInitialized = false; | |
| console.log("Viewer cleanup complete"); | |
| } | |
| // --- Initialize the 3D PLY Viewer --- | |
| async function initializeViewer() { | |
| // Skip initialization if already initialized | |
| if (isViewerInitialized) { | |
| console.log("Viewer already initialized, skipping initialization"); | |
| return; | |
| } | |
| console.log("Initializing PLY viewer..."); | |
| progressDialog.style.display = 'block'; | |
| progressIndicator.value = 0; | |
| try { | |
| // Load the SPLAT library if not already loaded | |
| if (!SPLAT) { | |
| console.log("Loading SPLAT library..."); | |
| SPLAT = await import("https://bilca-gsplat-library.static.hf.space/dist/index.js"); | |
| console.log("SPLAT library loaded successfully:", SPLAT); | |
| } | |
| // Create renderer | |
| console.log("Creating WebGL renderer..."); | |
| rendererInstance = new SPLAT.WebGLRenderer(canvas); | |
| console.log("Renderer created:", rendererInstance); | |
| // Create scene | |
| console.log("Creating scene..."); | |
| sceneInstance = new SPLAT.Scene(); | |
| console.log("Scene created:", sceneInstance); | |
| // Create camera | |
| console.log("Creating camera..."); | |
| cameraInstance = new SPLAT.Camera(); | |
| console.log("Camera created:", cameraInstance); | |
| // Set canvas background | |
| console.log("Setting canvas background..."); | |
| canvas.style.background = config.canvas_background || "#FEFEFD"; | |
| // Create controls | |
| console.log("Creating orbit controls..."); | |
| controlsInstance = new SPLAT.OrbitControls( | |
| cameraInstance, | |
| canvas, | |
| 0.5, | |
| 0.5, | |
| 5, | |
| true, | |
| new SPLAT.Vector3(), | |
| chosenInitAlpha, | |
| chosenInitBeta, | |
| chosenInitRadius | |
| ); | |
| // Set control constraints | |
| controlsInstance.maxZoom = maxZoom; | |
| controlsInstance.minZoom = minZoom; | |
| controlsInstance.minAngle = minAngle; | |
| controlsInstance.maxAngle = maxAngle; | |
| controlsInstance.minAzimuth = minAzimuth; | |
| controlsInstance.maxAzimuth = maxAzimuth; | |
| controlsInstance.panSpeed = isMobile ? 0.5 : 1.2; | |
| controlsInstance.update(); | |
| console.log("Orbit controls created and configured:", controlsInstance); | |
| // Handle resize | |
| console.log("Setting up resize handler..."); | |
| const handleResize = () => { | |
| if (rendererInstance) { | |
| rendererInstance.setSize(canvas.clientWidth, canvas.clientHeight); | |
| } | |
| }; | |
| // Initial resize | |
| handleResize(); | |
| // Add resize event listener | |
| resizeHandler = handleResize; | |
| window.addEventListener('resize', resizeHandler); | |
| console.log("Resize handler set up"); | |
| // Load PLY file | |
| console.log("Loading PLY file:", plyUrl); | |
| try { | |
| await SPLAT.PLYLoader.LoadAsync( | |
| plyUrl, | |
| sceneInstance, | |
| (progress) => { | |
| progressIndicator.value = progress * 100; | |
| console.log(`Loading progress: ${Math.round(progress * 100)}%`); | |
| } | |
| ); | |
| console.log("PLY file loaded successfully"); | |
| progressDialog.style.display = 'none'; | |
| // Check if scene has content | |
| console.log("Checking scene content..."); | |
| if (sceneInstance && sceneInstance.getChildren) { | |
| const children = sceneInstance.getChildren(); | |
| console.log("Scene children:", children); | |
| console.log("Scene children count:", children ? children.length : 0); | |
| } | |
| } catch (error) { | |
| console.error("Error loading PLY file:", error); | |
| progressDialog.innerHTML = `<p style="color: red">Error loading model: ${error.message}</p>`; | |
| return; | |
| } | |
| // Start animation loop | |
| console.log("Starting animation loop..."); | |
| const animate = () => { | |
| if (controlsInstance && sceneInstance && cameraInstance && rendererInstance) { | |
| controlsInstance.update(); | |
| rendererInstance.render(sceneInstance, cameraInstance); | |
| animFrameId = requestAnimationFrame(animate); | |
| } | |
| }; | |
| animFrameId = requestAnimationFrame(animate); | |
| console.log("Animation loop started with frame ID:", animFrameId); | |
| // Mark viewer as initialized | |
| isViewerInitialized = true; | |
| console.log("PLY viewer initialization complete"); | |
| } catch (error) { | |
| console.error("Error initializing PLY viewer:", error); | |
| progressDialog.innerHTML = `<p style="color: red">Error initializing viewer: ${error.message}</p>`; | |
| cleanupViewer(); | |
| } | |
| } | |
| })(); |