Spaces:
Running
Running
| (async function() { | |
| // Retrieve the current script tag and the config URL from its data 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; | |
| } | |
| // --- Outer scope variables for camera state --- | |
| let cameraInstance = null; | |
| let controlsInstance = null; | |
| let initialCameraPosition = null; | |
| let initialCameraRotation = null; | |
| // Generate a unique identifier for this widget instance. | |
| const instanceId = Math.random().toString(36).substr(2, 8); | |
| // Read required URLs and parameters from the config. | |
| // The gifUrl is no longer used. | |
| var plyUrl = config.ply_url; | |
| // Optional parameters for zoom and rotation limits. | |
| var minZoom = parseFloat(config.minZoom || "0"); | |
| var maxZoom = parseFloat(config.maxZoom || "20"); | |
| var minAngle = parseFloat(config.minAngle || "0"); | |
| var maxAngle = parseFloat(config.maxAngle || "360"); | |
| // Determine the aspect ratio. | |
| var aspectPercent = "100%"; | |
| if (config.aspect) { | |
| if (config.aspect.indexOf(":") !== -1) { | |
| var parts = config.aspect.split(":"); | |
| var w = parseFloat(parts[0]); | |
| var h = parseFloat(parts[1]); | |
| if (!isNaN(w) && !isNaN(h) && w > 0) { | |
| aspectPercent = (h / w * 100) + "%"; | |
| } | |
| } else { | |
| var aspectValue = parseFloat(config.aspect); | |
| if (!isNaN(aspectValue) && aspectValue > 0) { | |
| aspectPercent = (100 / aspectValue) + "%"; | |
| } | |
| } | |
| } else { | |
| var parentContainer = scriptTag.parentNode; | |
| var containerWidth = parentContainer.offsetWidth; | |
| var containerHeight = parentContainer.offsetHeight; | |
| if (containerWidth > 0 && containerHeight > 0) { | |
| aspectPercent = (containerHeight / containerWidth * 100) + "%"; | |
| } | |
| } | |
| var isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; | |
| // Inject CSS styles into the document head. | |
| var styleEl = document.createElement('style'); | |
| styleEl.textContent = ` | |
| /* Widget container styling */ | |
| #ply-widget-container-${instanceId} { | |
| position: relative; | |
| width: 100%; | |
| height: 0; | |
| padding-bottom: ${aspectPercent}; | |
| } | |
| /* When in fake fullscreen mode (iOS fallback) */ | |
| #ply-widget-container-${instanceId}.fake-fullscreen { | |
| position: fixed !important; | |
| top: 0 !important; | |
| left: 0 !important; | |
| width: 100vw !important; | |
| height: 100vh !important; | |
| padding-bottom: 0 !important; | |
| z-index: 9999 !important; | |
| } | |
| /* Viewer Container styling */ | |
| #viewer-container-${instanceId} { | |
| display: block; | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: #FEFEFD; | |
| border: 1px solid #474558; | |
| border-radius: 10px; | |
| } | |
| /* Canvas fills the viewer container */ | |
| #canvas-${instanceId} { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| } | |
| /* Progress dialog styling */ | |
| #progress-dialog-${instanceId} { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| border: none; | |
| background: rgba(255,255,255,0.9); | |
| padding: 20px; | |
| border-radius: 5px; | |
| z-index: 1000; | |
| display: none; | |
| } | |
| /* Menu (instructions) content styling */ | |
| #menu-content-${instanceId} { | |
| display: none; | |
| position: absolute; | |
| top: 70px; | |
| right: 15px; | |
| background: #FFFEF4; | |
| border: 1px solid #474558; | |
| border-radius: 5px; | |
| padding: 10px; | |
| font-size: 15px; | |
| line-height: 1.4; | |
| color: #474558; | |
| } | |
| /* Button styling */ | |
| .widget-button { | |
| position: absolute; | |
| width: 45px; | |
| height: 45px; | |
| background-color: #FFFEF4; | |
| border: 1px solid #474558; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| font-size: 14px; | |
| color: #474558; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #fullscreen-toggle-${instanceId} { | |
| top: 17px; | |
| right: 15px; | |
| } | |
| #help-toggle-${instanceId} { | |
| top: 17px; | |
| right: 70px; | |
| font-size: 22px; | |
| } | |
| #reset-camera-btn-${instanceId} { | |
| top: 17px; | |
| right: 123px; | |
| font-size: 22px; | |
| line-height: 1; | |
| padding: 0; | |
| } | |
| .reset-icon { | |
| display: inline-block; | |
| } | |
| `; | |
| document.head.appendChild(styleEl); | |
| // Create the widget container. | |
| var widgetContainer = document.createElement('div'); | |
| widgetContainer.id = 'ply-widget-container-' + instanceId; | |
| widgetContainer.innerHTML = ` | |
| <div id="viewer-container-${instanceId}"> | |
| <canvas id="canvas-${instanceId}"></canvas> | |
| <div id="progress-dialog-${instanceId}"> | |
| <progress id="progress-indicator-${instanceId}" max="100" value="0"></progress> | |
| </div> | |
| <button id="fullscreen-toggle-${instanceId}" class="widget-button">⇱</button> | |
| <button id="help-toggle-${instanceId}" class="widget-button">?</button> | |
| <button id="reset-camera-btn-${instanceId}" class="widget-button"> | |
| <span class="reset-icon">⟲</span> | |
| </button> | |
| <div id="menu-content-${instanceId}"> | |
| - Rotate with right click<br> | |
| - Zoom in/out with middle click<br> | |
| - Translate with left click | |
| </div> | |
| </div> | |
| `; | |
| scriptTag.parentNode.appendChild(widgetContainer); | |
| // Grab element references. | |
| var viewerContainer = document.getElementById('viewer-container-' + instanceId); | |
| var fullscreenToggle = document.getElementById('fullscreen-toggle-' + instanceId); | |
| var helpToggle = document.getElementById('help-toggle-' + instanceId); | |
| var resetCameraBtn = document.getElementById('reset-camera-btn-' + instanceId); | |
| var menuContent = document.getElementById('menu-content-' + instanceId); | |
| var canvas = document.getElementById('canvas-' + instanceId); | |
| var progressDialog = document.getElementById('progress-dialog-' + instanceId); | |
| var progressIndicator = document.getElementById('progress-indicator-' + instanceId); | |
| // --- Button Event Handlers --- | |
| fullscreenToggle.addEventListener('click', function() { | |
| if (isIOS) { | |
| widgetContainer.classList.toggle('fake-fullscreen'); | |
| fullscreenToggle.textContent = widgetContainer.classList.contains('fake-fullscreen') ? '⇲' : '⇱'; | |
| } else { | |
| if (!document.fullscreenElement) { | |
| widgetContainer.requestFullscreen ? widgetContainer.requestFullscreen() : null; | |
| } else { | |
| document.exitFullscreen ? document.exitFullscreen() : null; | |
| } | |
| } | |
| }); | |
| document.addEventListener('fullscreenchange', function() { | |
| fullscreenToggle.textContent = (document.fullscreenElement === widgetContainer) ? '⇲' : '⇱'; | |
| }); | |
| helpToggle.addEventListener('click', function(e) { | |
| e.stopPropagation(); | |
| menuContent.style.display = (menuContent.style.display === 'block') ? 'none' : 'block'; | |
| }); | |
| // Reset button now restores the camera to its original position, | |
| // as captured when the viewer was initialized. | |
| resetCameraBtn.addEventListener('click', function() { | |
| console.log("Reset camera button clicked."); | |
| if (cameraInstance && initialCameraPosition && initialCameraRotation) { | |
| cameraInstance.position = initialCameraPosition.clone(); | |
| cameraInstance.rotation = initialCameraRotation.clone(); | |
| if (typeof cameraInstance.update === 'function') { | |
| cameraInstance.update(); | |
| } | |
| if (controlsInstance && typeof controlsInstance.update === 'function') { | |
| controlsInstance.update(); | |
| } | |
| } | |
| }); | |
| // --- Initialize the 3D PLY Viewer --- | |
| async function initializeViewer() { | |
| const SPLAT = await import("https://cdn.jsdelivr.net/npm/gsplat@latest"); | |
| progressDialog.style.display = 'block'; | |
| const renderer = new SPLAT.WebGLRenderer(canvas); | |
| const scene = new SPLAT.Scene(); | |
| // Create the camera using default initialization. | |
| const camera = new SPLAT.Camera(); | |
| // Create OrbitControls after the camera is created. | |
| const controls = new SPLAT.OrbitControls(camera, canvas); | |
| cameraInstance = camera; | |
| controlsInstance = controls; | |
| // Capture the camera's initial values. | |
| initialCameraPosition = camera.position.clone(); | |
| initialCameraRotation = camera.rotation.clone(); | |
| canvas.style.background = "#FEFEFD"; | |
| controls.maxZoom = maxZoom; | |
| controls.minZoom = minZoom; | |
| controls.minAngle = minAngle; | |
| controls.maxAngle = maxAngle; | |
| controls.update(); | |
| try { | |
| await SPLAT.PLYLoader.LoadAsync( | |
| plyUrl, | |
| scene, | |
| (progress) => { | |
| progressIndicator.value = progress * 100; | |
| } | |
| ); | |
| progressDialog.style.display = 'none'; | |
| } catch (error) { | |
| console.error("Error loading PLY file:", error); | |
| progressDialog.innerHTML = `<p style="color: red">Error loading model: ${error.message}</p>`; | |
| } | |
| const frame = () => { | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| requestAnimationFrame(frame); | |
| }; | |
| const handleResize = () => { | |
| renderer.setSize(canvas.clientWidth, canvas.clientHeight); | |
| }; | |
| handleResize(); | |
| window.addEventListener("resize", handleResize); | |
| requestAnimationFrame(frame); | |
| } | |
| // Initialize the viewer immediately. | |
| initializeViewer(); | |
| })(); | |