Spaces:
Running
Running
/* viewer.js */ | |
(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; | |
} | |
// --- Outer scope variables for camera state --- | |
let cameraInstance = null; | |
let controlsInstance = null; | |
let initialCameraPosition = null; | |
let initialCameraRotation = null; | |
// We'll save the imported SPLAT module here so we can reuse it later. | |
let SPLAT = null; | |
// 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; | |
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 and set its inner HTML. | |
const widgetContainer = document.createElement('div'); | |
widgetContainer.id = 'ply-widget-container-' + instanceId; | |
widgetContainer.classList.add('ply-widget-container'); | |
// 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"> | |
<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 | |
`; | |
} | |
// 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; | |
} else { | |
gifPreview.style.display = 'none'; | |
viewerContainer.style.display = 'block'; | |
closeBtn.style.display = 'none'; | |
// Start the viewer immediately. | |
initializeViewer(); | |
} | |
// --- Button Event Handlers --- | |
if (gifUrl) { | |
gifPreview.addEventListener('click', function() { | |
gifPreview.style.display = 'none'; | |
viewerContainer.style.display = 'block'; | |
initializeViewer(); | |
}); | |
} | |
closeBtn.addEventListener('click', function() { | |
if (document.fullscreenElement === widgetContainer) { | |
if (document.exitFullscreen) { | |
document.exitFullscreen(); | |
} | |
} | |
if (widgetContainer.classList.contains('fake-fullscreen')) { | |
widgetContainer.classList.remove('fake-fullscreen'); | |
fullscreenToggle.textContent = '⇱'; | |
// For fake-fullscreen, reset camera immediately. | |
resetCamera(); | |
} | |
viewerContainer.style.display = 'none'; | |
gifPreview.style.display = 'block'; | |
}); | |
fullscreenToggle.addEventListener('click', function() { | |
if (isIOS) { | |
if (!widgetContainer.classList.contains('fake-fullscreen')) { | |
widgetContainer.classList.add('fake-fullscreen'); | |
} else { | |
widgetContainer.classList.remove('fake-fullscreen'); | |
// Reset camera when exiting fake-fullscreen. | |
resetCamera(); | |
} | |
fullscreenToggle.textContent = widgetContainer.classList.contains('fake-fullscreen') ? '⇲' : '⇱'; | |
} else { | |
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. | |
// Update the widget container styles so that in fullscreen | |
// the aspect ratio style is removed (height = 100% and padding-bottom = 0) and then reset the camera. | |
document.addEventListener('fullscreenchange', function() { | |
if (document.fullscreenElement === widgetContainer) { | |
fullscreenToggle.textContent = '⇲'; | |
widgetContainer.style.height = '100%'; | |
widgetContainer.style.paddingBottom = '0'; | |
resetCamera(); | |
} else { | |
fullscreenToggle.textContent = '⇱'; | |
// Restore the original aspect ratio style when not fullscreen. | |
widgetContainer.style.height = '0'; | |
widgetContainer.style.paddingBottom = aspectPercent; | |
resetCamera(); | |
} | |
}); | |
helpToggle.addEventListener('click', function(e) { | |
e.stopPropagation(); | |
menuContent.style.display = (menuContent.style.display === 'block') ? 'none' : 'block'; | |
}); | |
// --- Camera Reset Function --- | |
function resetCamera() { | |
console.log("Resetting camera to initial position."); | |
if (cameraInstance && initialCameraPosition && initialCameraRotation) { | |
// Reset camera position and rotation. | |
cameraInstance.position = initialCameraPosition.clone(); | |
cameraInstance.rotation = initialCameraRotation.clone(); | |
if (typeof cameraInstance.update === 'function') { | |
cameraInstance.update(); | |
} | |
// Dispose of the current controls. | |
if (controlsInstance && typeof controlsInstance.dispose === 'function') { | |
controlsInstance.dispose(); | |
} | |
// Re-create OrbitControls with the original chosen parameters. | |
controlsInstance = new SPLAT.OrbitControls( | |
cameraInstance, | |
canvas, | |
0.5, // default alpha fallback | |
0.5, // default beta fallback | |
5, // default radius fallback | |
true, | |
new SPLAT.Vector3(), | |
chosenInitAlpha, | |
chosenInitBeta, | |
chosenInitRadius | |
); | |
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(); | |
} | |
} | |
// Reset button now calls the resetCamera function. | |
resetCameraBtn.addEventListener('click', async function() { | |
console.log("Reset camera button clicked."); | |
resetCamera(); | |
}); | |
// Add keydown listener to exit fullscreen with Esc (or Echap) | |
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(); | |
} | |
} | |
}); | |
// --- Initialize the 3D PLY Viewer --- | |
async function initializeViewer() { | |
// Import SPLAT and store it globally. | |
SPLAT = await import("https://bilca-gsplat-library.static.hf.space/dist/index.js"); | |
progressDialog.style.display = 'block'; | |
const renderer = new SPLAT.WebGLRenderer(canvas); | |
const scene = new SPLAT.Scene(); | |
// Construct the camera. | |
const camera = new SPLAT.Camera(); | |
// Construct OrbitControls with the chosen initial orbit parameters. | |
controlsInstance = new SPLAT.OrbitControls( | |
camera, | |
canvas, | |
0.5, // default alpha fallback | |
0.5, // default beta fallback | |
5, // default radius fallback | |
true, | |
new SPLAT.Vector3(), | |
chosenInitAlpha, | |
chosenInitBeta, | |
chosenInitRadius | |
); | |
cameraInstance = camera; | |
// Save the initial camera state (after controls are created) | |
initialCameraPosition = camera.position.clone(); | |
initialCameraRotation = camera.rotation.clone(); | |
canvas.style.background = "#FEFEFD"; | |
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(); | |
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 = () => { | |
controlsInstance.update(); | |
renderer.render(scene, camera); | |
requestAnimationFrame(frame); | |
}; | |
const handleResize = () => { | |
renderer.setSize(canvas.clientWidth, canvas.clientHeight); | |
}; | |
handleResize(); | |
window.addEventListener("resize", handleResize); | |
requestAnimationFrame(frame); | |
} | |
// If a gif_url exists, the viewer is started on preview click; | |
// otherwise, it was already started above. | |
})(); | |