Spaces:
Running
Running
// Store the current script reference before the async function runs | |
const currentScriptTag = document.currentScript; | |
(async function() { | |
// Import PlayCanvas | |
const pc = await import("https://cdn.skypack.dev/[email protected]"); | |
window.pc = pc; | |
// Find the script tag using a more reliable method | |
let scriptTag = currentScriptTag; | |
// Fallback method if currentScriptTag is null | |
if (!scriptTag) { | |
const scripts = document.getElementsByTagName('script'); | |
for (let i = 0; i < scripts.length; i++) { | |
if (scripts[i].src.includes('index.js') && scripts[i].hasAttribute('data-config')) { | |
scriptTag = scripts[i]; | |
break; | |
} | |
} | |
// If still not found, try the last script on the page | |
if (!scriptTag && scripts.length > 0) { | |
scriptTag = scripts[scripts.length - 1]; | |
} | |
} | |
// Check if we found a script tag | |
if (!scriptTag) { | |
console.error("Could not find the script tag with data-config attribute."); | |
return; | |
} | |
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); | |
} | |
// --- Outer scope variables --- | |
let cameraEntity = null; | |
let app = null; | |
let modelEntity = 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; | |
// Camera constraint parameters | |
const minZoom = parseFloat(config.minZoom || "1"); | |
const maxZoom = parseFloat(config.maxZoom || "20"); | |
const minAngle = parseFloat(config.minAngle || "-45"); | |
const maxAngle = parseFloat(config.maxAngle || "90"); | |
const minAzimuth = config.minAzimuth !== undefined ? parseFloat(config.minAzimuth) : -360; | |
const maxAzimuth = config.maxAzimuth !== undefined ? parseFloat(config.maxAzimuth) : 360; | |
// Model position, scale, and rotation parameters | |
const modelX = config.modelX !== undefined ? parseFloat(config.modelX) : 0; | |
const modelY = config.modelY !== undefined ? parseFloat(config.modelY) : 0; | |
const modelZ = config.modelZ !== undefined ? parseFloat(config.modelZ) : 0; | |
const modelScale = config.modelScale !== undefined ? parseFloat(config.modelScale) : 1; | |
const modelRotationX = config.modelRotationX !== undefined ? parseFloat(config.modelRotationX) : 0; | |
const modelRotationY = config.modelRotationY !== undefined ? parseFloat(config.modelRotationY) : 0; | |
const modelRotationZ = config.modelRotationZ !== undefined ? parseFloat(config.modelRotationZ) : 0; | |
// Direct camera coordinates | |
const cameraX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0; | |
const cameraY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 2; | |
const cameraZ = config.cameraZ !== undefined ? parseFloat(config.cameraZ) : 5; | |
// Camera coordinates for mobile devices | |
const cameraXPhone = config.cameraXPhone !== undefined ? parseFloat(config.cameraXPhone) : cameraX; | |
const cameraYPhone = config.cameraYPhone !== undefined ? parseFloat(config.cameraYPhone) : cameraY; | |
const cameraZPhone = config.cameraZPhone !== undefined ? parseFloat(config.cameraZPhone) : cameraZ * 1.5; | |
// 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 coordinates based on device type | |
const chosenCameraX = isMobile ? cameraXPhone : cameraX; | |
const chosenCameraY = isMobile ? cameraYPhone : cameraY; | |
const chosenCameraZ = isMobile ? cameraZPhone : cameraZ; | |
// 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"> | |
<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); | |
// Flag to track if mouse is over the viewer | |
let isMouseOverViewer = false; | |
// Add mouse hover tracking for the viewer container | |
viewerContainer.addEventListener('mouseenter', function() { | |
isMouseOverViewer = true; | |
}); | |
viewerContainer.addEventListener('mouseleave', function() { | |
isMouseOverViewer = false; | |
}); | |
// 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'; | |
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 = '⇱'; | |
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'); | |
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. | |
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(); | |
} | |
}); | |
helpToggle.addEventListener('click', function(e) { | |
e.stopPropagation(); | |
menuContent.style.display = (menuContent.style.display === 'block') ? 'none' : 'block'; | |
}); | |
// --- Camera Reset Function --- | |
function resetCamera() { | |
if (!cameraEntity || !modelEntity || !app) return; | |
try { | |
// Get the orbit camera script | |
const orbitCam = cameraEntity.script.orbitCamera; | |
if (!orbitCam) return; | |
// Store model position | |
const modelPos = modelEntity.getPosition(); | |
// 1. Create a temporary entity to help calculate new values | |
const tempEntity = new pc.Entity(); | |
tempEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ); | |
tempEntity.lookAt(modelPos); | |
// 2. Calculate the distance between camera and model | |
const distance = new pc.Vec3().sub2( | |
new pc.Vec3(chosenCameraX, chosenCameraY, chosenCameraZ), | |
modelPos | |
).length(); | |
// 3. Set camera position | |
cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ); | |
cameraEntity.lookAt(modelPos); | |
// 4. Update the orbit camera's pivot point | |
orbitCam.pivotPoint = new pc.Vec3(modelPos.x, modelPos.y, modelPos.z); | |
// 5. Set the distance | |
orbitCam._targetDistance = distance; | |
orbitCam._distance = distance; | |
// 6. Calculate and set yaw and pitch from the camera's rotation | |
const rotation = tempEntity.getRotation(); | |
const tempForward = new pc.Vec3(); | |
rotation.transformVector(pc.Vec3.FORWARD, tempForward); | |
const yaw = Math.atan2(-tempForward.x, -tempForward.z) * pc.math.RAD_TO_DEG; | |
const yawQuat = new pc.Quat().setFromEulerAngles(0, -yaw, 0); | |
const rotWithoutYaw = new pc.Quat().mul2(yawQuat, rotation); | |
const forwardWithoutYaw = new pc.Vec3(); | |
rotWithoutYaw.transformVector(pc.Vec3.FORWARD, forwardWithoutYaw); | |
const pitch = Math.atan2(forwardWithoutYaw.y, -forwardWithoutYaw.z) * pc.math.RAD_TO_DEG; | |
// Set yaw and pitch directly on internal variables | |
orbitCam._targetYaw = yaw; | |
orbitCam._yaw = yaw; | |
orbitCam._targetPitch = pitch; | |
orbitCam._pitch = pitch; | |
// Force update | |
if (typeof orbitCam._updatePosition === 'function') { | |
orbitCam._updatePosition(); | |
} | |
// Clean up | |
tempEntity.destroy(); | |
} catch (error) { | |
console.error("Error resetting camera:", error); | |
} | |
} | |
resetCameraBtn.addEventListener('click', function() { | |
console.log("Reset camera button clicked."); | |
resetCamera(); | |
}); | |
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(); | |
} | |
} | |
}); | |
// --- Prevent app from hijacking all wheel events --- | |
const handleWheel = function(event) { | |
// Check if mouse is over the viewer | |
if (!isMouseOverViewer) { | |
// Allow normal page scrolling | |
return true; | |
} | |
// Otherwise apply zooming, but prevent default only for viewer area | |
event.stopPropagation(); | |
if (cameraEntity && cameraEntity.script && cameraEntity.script.orbitCamera) { | |
const camera = cameraEntity.camera; | |
const orbitCamera = cameraEntity.script.orbitCamera; | |
const sensitivity = cameraEntity.script.orbitCameraInputMouse ? | |
cameraEntity.script.orbitCameraInputMouse.distanceSensitivity || 0.4 : 0.4; | |
if (camera.projection === pc.PROJECTION_PERSPECTIVE) { | |
orbitCamera.distance -= event.deltaY * 0.01 * sensitivity * (orbitCamera.distance * 0.1); | |
} else { | |
orbitCamera.orthoHeight -= event.deltaY * 0.01 * sensitivity * (orbitCamera.orthoHeight * 0.1); | |
} | |
event.preventDefault(); | |
} | |
}; | |
// --- Listen for wheel events at the viewer level, not document level --- | |
viewerContainer.addEventListener('wheel', handleWheel, { passive: false }); | |
canvas.addEventListener('wheel', handleWheel, { passive: false }); | |
// --- Initialize the 3D PLY Viewer using PlayCanvas --- | |
async function initializeViewer() { | |
progressDialog.style.display = 'block'; | |
// Initialize PlayCanvas | |
const deviceType = "webgl2"; | |
const gfxOptions = { | |
deviceTypes: [deviceType], | |
glslangUrl: `https://playcanvas.vercel.app/static/lib/glslang/glslang.js`, | |
twgslUrl: `https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js`, | |
antialias: false | |
}; | |
try { | |
// Create graphics device | |
const device = await pc.createGraphicsDevice(canvas, gfxOptions); | |
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2); | |
// Create app | |
const createOptions = new pc.AppOptions(); | |
createOptions.graphicsDevice = device; | |
createOptions.mouse = new pc.Mouse(canvas); // Use canvas, not document.body | |
createOptions.touch = new pc.TouchDevice(canvas); // Use canvas, not document.body | |
createOptions.componentSystems = [ | |
pc.RenderComponentSystem, | |
pc.CameraComponentSystem, | |
pc.LightComponentSystem, | |
pc.ScriptComponentSystem, | |
pc.GSplatComponentSystem | |
]; | |
createOptions.resourceHandlers = [ | |
pc.TextureHandler, | |
pc.ContainerHandler, | |
pc.ScriptHandler, | |
pc.GSplatHandler | |
]; | |
app = new pc.AppBase(canvas); | |
app.init(createOptions); | |
// Set canvas fill mode to match the container | |
app.setCanvasFillMode(pc.FILLMODE_NONE); | |
app.setCanvasResolution(pc.RESOLUTION_AUTO); | |
// Set scene options | |
app.scene.exposure = 0.8; | |
app.scene.toneMapping = pc.TONEMAP_ACES; | |
// Handle window resizing | |
const resize = () => { | |
if (app) { | |
app.resizeCanvas(canvas.clientWidth, canvas.clientHeight); | |
} | |
}; | |
window.addEventListener('resize', resize); | |
app.on('destroy', () => window.removeEventListener('resize', resize)); | |
// Load required assets | |
const assets = { | |
model: new pc.Asset('gsplat', 'gsplat', { url: plyUrl }), | |
orbit: new pc.Asset('script', 'script', { url: `https://bilca-visionneur-play-canva-2.static.hf.space/orbit-camera.js` }) | |
}; | |
// Create asset loader with progress tracking | |
const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets); | |
// Handle asset loading progress | |
let lastProgress = 0; | |
assets.model.on('load', (asset) => { | |
progressDialog.style.display = 'none'; | |
}); | |
assets.model.on('error', (err) => { | |
console.error("Error loading PLY file:", err); | |
progressDialog.innerHTML = `<p style="color: red">Error loading model: ${err}</p>`; | |
}); | |
// Set up progress monitoring | |
const checkProgress = () => { | |
if (app && assets.model.resource) { | |
progressIndicator.value = 100; | |
clearInterval(progressChecker); | |
progressDialog.style.display = 'none'; | |
} else if (assets.model.loading) { | |
// Increment progress for visual feedback | |
lastProgress += 2; | |
if (lastProgress > 90) lastProgress = 90; // Cap at 90% until fully loaded | |
progressIndicator.value = lastProgress; | |
} | |
}; | |
const progressChecker = setInterval(checkProgress, 100); | |
// Load assets and set up scene | |
assetListLoader.load(() => { | |
app.start(); | |
// Create model entity | |
modelEntity = new pc.Entity('model'); | |
modelEntity.addComponent('gsplat', { | |
asset: assets.model | |
}); | |
// Position the model using JSON parameters | |
modelEntity.setLocalPosition(modelX, modelY, modelZ); | |
modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ); | |
modelEntity.setLocalScale(modelScale, modelScale, modelScale); | |
app.root.addChild(modelEntity); | |
// Create camera entity | |
cameraEntity = new pc.Entity('camera'); | |
cameraEntity.addComponent('camera', { | |
clearColor: new pc.Color( | |
config.canvas_background ? parseInt(config.canvas_background.substr(1, 2), 16) / 255 : 0, | |
config.canvas_background ? parseInt(config.canvas_background.substr(3, 2), 16) / 255 : 0, | |
config.canvas_background ? parseInt(config.canvas_background.substr(5, 2), 16) / 255 : 0 | |
), | |
toneMapping: pc.TONEMAP_ACES | |
}); | |
// Set camera position directly using X, Y, Z coordinates from config | |
cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ); | |
cameraEntity.lookAt(modelEntity.getPosition()); | |
// Add orbit camera script for interactive navigation | |
cameraEntity.addComponent('script'); | |
cameraEntity.script.create('orbitCamera', { | |
attributes: { | |
inertiaFactor: 0.2, | |
focusEntity: modelEntity, | |
distanceMax: maxZoom, | |
distanceMin: minZoom, | |
pitchAngleMax: maxAngle, | |
pitchAngleMin: minAngle, | |
yawAngleMax: maxAzimuth, | |
yawAngleMin: minAzimuth, | |
frameOnStart: false // Don't auto-frame since we're setting position directly | |
} | |
}); | |
// Create input controllers but don't add mouse wheel handling - we handle that separately | |
cameraEntity.script.create('orbitCameraInputMouse', { | |
attributes: { | |
orbitSensitivity: isMobile ? 0.6 : 0.3, | |
distanceSensitivity: isMobile ? 0.5 : 0.4 | |
} | |
}); | |
// Disable wheel event in the orbit camera input | |
if (cameraEntity.script.orbitCameraInputMouse) { | |
// Override mouse wheel to do nothing - we handle wheel events separately | |
cameraEntity.script.orbitCameraInputMouse.onMouseWheel = function() {}; | |
} | |
// Add touch input controller | |
cameraEntity.script.create('orbitCameraInputTouch', { | |
attributes: { | |
orbitSensitivity: 0.6, | |
distanceSensitivity: 0.5 | |
} | |
}); | |
// Initialize the orbit controller | |
setTimeout(() => { | |
if (cameraEntity.script.orbitCamera) { | |
// Calculate distance from camera to model | |
const modelPos = modelEntity.getPosition(); | |
const camPos = cameraEntity.getPosition(); | |
const distanceVec = new pc.Vec3(); | |
distanceVec.sub2(camPos, modelPos); | |
const distance = distanceVec.length(); | |
// Set up the orbit controller | |
cameraEntity.script.orbitCamera.pivotPoint.copy(modelPos); | |
cameraEntity.script.orbitCamera.distance = distance; | |
cameraEntity.script.orbitCamera._removeInertia(); | |
} | |
}, 100); | |
app.root.addChild(cameraEntity); | |
// Initial resize to match container | |
resize(); | |
// Hide progress dialog when everything is set up | |
progressDialog.style.display = 'none'; | |
}); | |
} catch (error) { | |
console.error("Error initializing PlayCanvas viewer:", error); | |
progressDialog.innerHTML = `<p style="color: red">Error loading viewer: ${error.message}</p>`; | |
} | |
} | |
})(); |