Spaces:
Running
Running
(function() { | |
// Helper: Get query parameters from THIS script’s src URL. | |
function getScriptQueryParam(param) { | |
var params = new URLSearchParams(""); | |
if (document.currentScript && document.currentScript.src.indexOf('?') !== -1) { | |
var queryString = document.currentScript.src.split('?')[1]; | |
params = new URLSearchParams(queryString); | |
} else { | |
params = new URLSearchParams(window.location.search); | |
} | |
return params.get(param); | |
} | |
// Read required URLs and optional limits from the query parameters. | |
var gifUrl = getScriptQueryParam("gif_url"); | |
var plyUrl = getScriptQueryParam("ply_url"); | |
// Optional parameters for zoom and angle limits: | |
var minZoom = parseFloat(getScriptQueryParam("minZoom")) || 1.5; | |
var maxZoom = parseFloat(getScriptQueryParam("maxZoom")) || 5; | |
var minAngle = parseFloat(getScriptQueryParam("minAngle")) || 0; | |
var maxAngle = parseFloat(getScriptQueryParam("maxAngle")) || 90; | |
// Detect if the device is iOS. | |
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 { | |
position: relative; | |
width: 100%; | |
height: 0; | |
padding-bottom: 100%; | |
} | |
/* When in fake fullscreen mode (iOS fallback) */ | |
#ply-widget-container.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; | |
} | |
/* GIF Preview styling */ | |
#gif-preview-container { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
border: 1px solid #474558; | |
border-radius: 10px; | |
overflow: hidden; | |
cursor: pointer; | |
} | |
#gif-preview-container img { | |
width: 100%; | |
height: 100%; | |
object-fit: cover; | |
} | |
/* Viewer Container styling */ | |
#viewer-container { | |
display: none; | |
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 { | |
width: 100%; | |
height: 100%; | |
display: block; | |
} | |
/* Progress dialog styling (as a centered div) */ | |
#progress-dialog { | |
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 { | |
display: none; | |
position: absolute; | |
top: 72px; | |
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; | |
} | |
/* Positions: Close at top-left, fullscreen at top-right, help (instructions) below fullscreen */ | |
#close-btn { | |
top: 17px; | |
left: 15px; | |
} | |
#fullscreen-toggle { | |
top: 17px; | |
right: 15px; | |
} | |
#help-toggle { | |
top: 72px; | |
right: 15px; | |
} | |
`; | |
document.head.appendChild(styleEl); | |
// Create the widget container and set its inner HTML. | |
var widgetContainer = document.createElement('div'); | |
widgetContainer.id = 'ply-widget-container'; | |
widgetContainer.innerHTML = ` | |
<!-- GIF Preview Container --> | |
<div id="gif-preview-container"> | |
<img id="preview-image" alt="Preview" crossorigin="anonymous"> | |
</div> | |
<!-- Viewer Container --> | |
<div id="viewer-container"> | |
<canvas id="canvas"></canvas> | |
<div id="progress-dialog"> | |
<progress id="progress-indicator" max="100" value="0"></progress> | |
</div> | |
<button id="close-btn" class="widget-button">X</button> | |
<button id="fullscreen-toggle" class="widget-button">⇱</button> | |
<button id="help-toggle" class="widget-button">?</button> | |
<div id="menu-content"> | |
- Rotate with right click<br> | |
- Zoom in/out with middle click<br> | |
- Translate with left click | |
</div> | |
</div> | |
`; | |
// Append widgetContainer to the current script's parent so it appears in place. | |
document.currentScript.parentNode.appendChild(widgetContainer); | |
// Grab element references. | |
var gifPreview = document.getElementById('gif-preview-container'); | |
var viewerContainer = document.getElementById('viewer-container'); | |
var previewImage = document.getElementById('preview-image'); | |
var closeBtn = document.getElementById('close-btn'); | |
var fullscreenToggle = document.getElementById('fullscreen-toggle'); | |
var helpToggle = document.getElementById('help-toggle'); | |
var menuContent = document.getElementById('menu-content'); | |
var canvas = document.getElementById('canvas'); | |
var progressDialog = document.getElementById('progress-dialog'); | |
var progressIndicator = document.getElementById('progress-indicator'); | |
// Set the preview image if provided. | |
if (gifUrl) { | |
previewImage.src = gifUrl; | |
} | |
// --- Button Event Handlers --- | |
// When the preview image is clicked, hide it, show the viewer, and initialize the 3D viewer. | |
gifPreview.addEventListener('click', function() { | |
gifPreview.style.display = 'none'; | |
viewerContainer.style.display = 'block'; | |
initializeViewer(); | |
}); | |
// Close button: hide the viewer and show the preview. | |
closeBtn.addEventListener('click', function() { | |
// Exit fullscreen if active. | |
if (document.fullscreenElement === widgetContainer) { | |
if (document.exitFullscreen) { | |
document.exitFullscreen(); | |
} | |
} | |
// Remove fake-fullscreen class (for iOS) if present. | |
widgetContainer.classList.remove('fake-fullscreen'); | |
viewerContainer.style.display = 'none'; | |
gifPreview.style.display = 'block'; | |
}); | |
// Fullscreen toggle: use native Fullscreen API if available; otherwise, for iOS, toggle a CSS-based fullscreen. | |
fullscreenToggle.addEventListener('click', function() { | |
if (isIOS) { | |
// Toggle fake fullscreen via CSS on iOS. | |
if (!widgetContainer.classList.contains('fake-fullscreen')) { | |
widgetContainer.classList.add('fake-fullscreen'); | |
} else { | |
widgetContainer.classList.remove('fake-fullscreen'); | |
} | |
// Update icon based on state. | |
fullscreenToggle.textContent = widgetContainer.classList.contains('fake-fullscreen') ? '⇲' : '⇱'; | |
} else { | |
// Non-iOS: use standard Fullscreen API. | |
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(); | |
} | |
} | |
} | |
}); | |
// Update the fullscreen button icon on fullscreen change (for non-iOS browsers). | |
document.addEventListener('fullscreenchange', function() { | |
if (document.fullscreenElement === widgetContainer) { | |
fullscreenToggle.textContent = '⇲'; | |
} else { | |
fullscreenToggle.textContent = '⇱'; | |
} | |
}); | |
// Help (instructions) toggle: show/hide the instructions. | |
helpToggle.addEventListener('click', function(e) { | |
e.stopPropagation(); | |
menuContent.style.display = (menuContent.style.display === 'block') ? 'none' : 'block'; | |
}); | |
// --- Initialize the 3D PLY Viewer --- | |
async function initializeViewer() { | |
// Dynamically import the gsplat library. | |
const SPLAT = await import("https://cdn.jsdelivr.net/npm/gsplat@latest"); | |
// Display the progress dialog by setting its display style to 'block' | |
progressDialog.style.display = 'block'; | |
// Create renderer, scene, camera, and controls. | |
const renderer = new SPLAT.WebGLRenderer(canvas); | |
const scene = new SPLAT.Scene(); | |
const camera = new SPLAT.Camera(); | |
const controls = new SPLAT.OrbitControls(camera, canvas); | |
canvas.style.background = "#FEFEFD"; | |
controls.maxZoom = maxZoom; | |
controls.minZoom = minZoom; | |
controls.minAngle = minAngle; | |
controls.maxAngle = maxAngle; | |
controls.update(); | |
// Load the PLY model from the provided URL. | |
try { | |
await SPLAT.PLYLoader.LoadAsync( | |
plyUrl, | |
scene, | |
(progress) => { | |
progressIndicator.value = progress * 100; | |
} | |
); | |
// Hide the progress dialog once the model is loaded. | |
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>`; | |
} | |
// Render loop and resize handling. | |
const handleResize = () => { | |
renderer.setSize(canvas.clientWidth, canvas.clientHeight); | |
}; | |
const frame = () => { | |
controls.update(); | |
renderer.render(scene, camera); | |
requestAnimationFrame(frame); | |
}; | |
handleResize(); | |
window.addEventListener("resize", handleResize); | |
requestAnimationFrame(frame); | |
} | |
})(); | |