bilca's picture
Update js_scripts/index.js
815f10d verified
raw
history blame
22.9 kB
// 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>`;
}
}
})();