// 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/playcanvas@v1.68.0"); 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 = `
Preview
`; 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.
- Pour orbiter, utilisez un doigt.
- Pour zoomer, pincez avec deux doigts. `; } else { menuContent.innerHTML = ` - orbitez avec le clic droit
- zoomez avec la molette
- 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 = `

Error loading model: ${err}

`; }); // 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 = `

Error loading viewer: ${error.message}

`; } } })();