Spaces:
Running
Running
Update js_scripts/index.js
Browse files- js_scripts/index.js +756 -60
js_scripts/index.js
CHANGED
@@ -1,66 +1,762 @@
|
|
1 |
-
//
|
|
|
2 |
|
3 |
-
|
4 |
-
//
|
5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
|
7 |
-
//
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
),
|
15 |
-
toneMapping: pc.TONEMAP_ACES
|
16 |
-
});
|
17 |
|
18 |
-
//
|
19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
|
21 |
-
//
|
22 |
-
|
23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
65 |
}
|
66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Store the current script reference before the async function runs
|
2 |
+
const currentScriptTag = document.currentScript;
|
3 |
|
4 |
+
(async function() {
|
5 |
+
// Import PlayCanvas
|
6 |
+
const pc = await import("https://cdn.skypack.dev/[email protected]");
|
7 |
+
window.pc = pc;
|
8 |
+
|
9 |
+
// Find the script tag using a more reliable method
|
10 |
+
let scriptTag = currentScriptTag;
|
11 |
+
|
12 |
+
// Fallback method if currentScriptTag is null
|
13 |
+
if (!scriptTag) {
|
14 |
+
const scripts = document.getElementsByTagName('script');
|
15 |
+
for (let i = 0; i < scripts.length; i++) {
|
16 |
+
if (scripts[i].src.includes('index.js') && scripts[i].hasAttribute('data-config')) {
|
17 |
+
scriptTag = scripts[i];
|
18 |
+
break;
|
19 |
+
}
|
20 |
+
}
|
21 |
+
|
22 |
+
// If still not found, try the last script on the page
|
23 |
+
if (!scriptTag && scripts.length > 0) {
|
24 |
+
scriptTag = scripts[scripts.length - 1];
|
25 |
+
}
|
26 |
+
}
|
27 |
+
|
28 |
+
// Check if we found a script tag
|
29 |
+
if (!scriptTag) {
|
30 |
+
console.error("Could not find the script tag with data-config attribute.");
|
31 |
+
return;
|
32 |
+
}
|
33 |
+
|
34 |
+
const configUrl = scriptTag.getAttribute("data-config");
|
35 |
+
let config = {};
|
36 |
+
if (configUrl) {
|
37 |
+
try {
|
38 |
+
const response = await fetch(configUrl);
|
39 |
+
config = await response.json();
|
40 |
+
} catch (error) {
|
41 |
+
console.error("Error loading config file:", error);
|
42 |
+
return;
|
43 |
+
}
|
44 |
+
} else {
|
45 |
+
console.error("No config file provided. Please set a data-config attribute on the script tag.");
|
46 |
+
return;
|
47 |
+
}
|
48 |
|
49 |
+
// Load the external CSS file if provided in the config.
|
50 |
+
if (config.css_url) {
|
51 |
+
const linkEl = document.createElement("link");
|
52 |
+
linkEl.rel = "stylesheet";
|
53 |
+
linkEl.href = config.css_url;
|
54 |
+
document.head.appendChild(linkEl);
|
55 |
+
}
|
|
|
|
|
|
|
56 |
|
57 |
+
// --- Outer scope variables ---
|
58 |
+
let cameraEntity = null;
|
59 |
+
let app = null;
|
60 |
+
let modelEntity = null;
|
61 |
+
let viewerInitialized = false;
|
62 |
+
let wheelHandlers = [];
|
63 |
+
let resizeHandler = null;
|
64 |
+
let progressChecker = null;
|
65 |
+
|
66 |
+
// Generate a unique identifier for this widget instance.
|
67 |
+
const instanceId = Math.random().toString(36).substr(2, 8);
|
68 |
+
|
69 |
+
// Read configuration values from the JSON file.
|
70 |
+
const gifUrl = config.gif_url;
|
71 |
+
const plyUrl = config.ply_url;
|
72 |
+
|
73 |
+
// Camera constraint parameters
|
74 |
+
const minZoom = parseFloat(config.minZoom || "1");
|
75 |
+
const maxZoom = parseFloat(config.maxZoom || "20");
|
76 |
+
const minAngle = parseFloat(config.minAngle || "-45");
|
77 |
+
const maxAngle = parseFloat(config.maxAngle || "90");
|
78 |
+
const minAzimuth = config.minAzimuth !== undefined ? parseFloat(config.minAzimuth) : -360;
|
79 |
+
const maxAzimuth = config.maxAzimuth !== undefined ? parseFloat(config.maxAzimuth) : 360;
|
80 |
+
|
81 |
+
// Model position, scale, and rotation parameters
|
82 |
+
const modelX = config.modelX !== undefined ? parseFloat(config.modelX) : 0;
|
83 |
+
const modelY = config.modelY !== undefined ? parseFloat(config.modelY) : 0;
|
84 |
+
const modelZ = config.modelZ !== undefined ? parseFloat(config.modelZ) : 0;
|
85 |
+
|
86 |
+
const modelScale = config.modelScale !== undefined ? parseFloat(config.modelScale) : 1;
|
87 |
+
|
88 |
+
const modelRotationX = config.modelRotationX !== undefined ? parseFloat(config.modelRotationX) : 0;
|
89 |
+
const modelRotationY = config.modelRotationY !== undefined ? parseFloat(config.modelRotationY) : 0;
|
90 |
+
const modelRotationZ = config.modelRotationZ !== undefined ? parseFloat(config.modelRotationZ) : 0;
|
91 |
+
|
92 |
+
// Direct camera coordinates
|
93 |
+
const cameraX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0;
|
94 |
+
const cameraY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 2;
|
95 |
+
const cameraZ = config.cameraZ !== undefined ? parseFloat(config.cameraZ) : 5;
|
96 |
+
|
97 |
+
// Camera coordinates for mobile devices
|
98 |
+
const cameraXPhone = config.cameraXPhone !== undefined ? parseFloat(config.cameraXPhone) : cameraX;
|
99 |
+
const cameraYPhone = config.cameraYPhone !== undefined ? parseFloat(config.cameraYPhone) : cameraY;
|
100 |
+
const cameraZPhone = config.cameraZPhone !== undefined ? parseFloat(config.cameraZPhone) : cameraZ * 1.5;
|
101 |
|
102 |
+
// Detect if the device is iOS.
|
103 |
+
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
104 |
+
// Also detect Android devices.
|
105 |
+
const isMobile = isIOS || /Android/i.test(navigator.userAgent);
|
106 |
+
|
107 |
+
// Choose the appropriate coordinates based on device type
|
108 |
+
const chosenCameraX = isMobile ? cameraXPhone : cameraX;
|
109 |
+
const chosenCameraY = isMobile ? cameraYPhone : cameraY;
|
110 |
+
const chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
|
111 |
+
|
112 |
+
console.log(`Device detected: ${isMobile ? 'Mobile' : 'Desktop'}`);
|
113 |
+
console.log(`Camera coordinates chosen: (${chosenCameraX}, ${chosenCameraY}, ${chosenCameraZ})`);
|
114 |
+
|
115 |
+
// Determine the aspect ratio.
|
116 |
+
let aspectPercent = "100%";
|
117 |
+
if (config.aspect) {
|
118 |
+
if (config.aspect.indexOf(":") !== -1) {
|
119 |
+
const parts = config.aspect.split(":");
|
120 |
+
const w = parseFloat(parts[0]);
|
121 |
+
const h = parseFloat(parts[1]);
|
122 |
+
if (!isNaN(w) && !isNaN(h) && w > 0) {
|
123 |
+
aspectPercent = (h / w * 100) + "%";
|
124 |
+
}
|
125 |
+
} else {
|
126 |
+
const aspectValue = parseFloat(config.aspect);
|
127 |
+
if (!isNaN(aspectValue) && aspectValue > 0) {
|
128 |
+
aspectPercent = (100 / aspectValue) + "%";
|
129 |
+
}
|
130 |
+
}
|
131 |
+
} else {
|
132 |
+
const parentContainer = scriptTag.parentNode;
|
133 |
+
const containerWidth = parentContainer.offsetWidth;
|
134 |
+
const containerHeight = parentContainer.offsetHeight;
|
135 |
+
if (containerWidth > 0 && containerHeight > 0) {
|
136 |
+
aspectPercent = (containerHeight / containerWidth * 100) + "%";
|
137 |
+
}
|
138 |
+
}
|
139 |
+
|
140 |
+
// Create the widget container.
|
141 |
+
const widgetContainer = document.createElement('div');
|
142 |
+
widgetContainer.id = 'ply-widget-container-' + instanceId;
|
143 |
+
widgetContainer.classList.add('ply-widget-container');
|
144 |
+
// Add a mobile class if on a phone.
|
145 |
+
if (isMobile) {
|
146 |
+
widgetContainer.classList.add('mobile');
|
147 |
+
}
|
148 |
+
// Set inline style for aspect ratio.
|
149 |
+
widgetContainer.style.height = "0";
|
150 |
+
widgetContainer.style.paddingBottom = aspectPercent;
|
151 |
|
152 |
+
widgetContainer.innerHTML = `
|
153 |
+
<!-- GIF Preview Container -->
|
154 |
+
<div id="gif-preview-container-${instanceId}" class="gif-preview-container">
|
155 |
+
<img id="preview-image-${instanceId}" alt="Preview" crossorigin="anonymous">
|
156 |
+
</div>
|
157 |
+
<!-- Viewer Container -->
|
158 |
+
<div id="viewer-container-${instanceId}" class="viewer-container" style="display: none;">
|
159 |
+
<canvas id="canvas-${instanceId}" class="ply-canvas"></canvas>
|
160 |
+
<div id="progress-dialog-${instanceId}" class="progress-dialog">
|
161 |
+
<progress id="progress-indicator-${instanceId}" max="100" value="0"></progress>
|
162 |
+
</div>
|
163 |
+
<button id="close-btn-${instanceId}" class="widget-button close-btn">X</button>
|
164 |
+
<button id="fullscreen-toggle-${instanceId}" class="widget-button fullscreen-toggle">⇱</button>
|
165 |
+
<button id="help-toggle-${instanceId}" class="widget-button help-toggle">?</button>
|
166 |
+
<button id="reset-camera-btn-${instanceId}" class="widget-button reset-camera-btn">
|
167 |
+
<span class="reset-icon">⟲</span>
|
168 |
+
</button>
|
169 |
+
<div id="menu-content-${instanceId}" class="menu-content"></div>
|
170 |
+
</div>
|
171 |
+
`;
|
172 |
+
scriptTag.parentNode.appendChild(widgetContainer);
|
173 |
+
|
174 |
+
// Grab element references.
|
175 |
+
const gifPreview = document.getElementById('gif-preview-container-' + instanceId);
|
176 |
+
const viewerContainer = document.getElementById('viewer-container-' + instanceId);
|
177 |
+
const previewImage = document.getElementById('preview-image-' + instanceId);
|
178 |
+
const closeBtn = document.getElementById('close-btn-' + instanceId);
|
179 |
+
const fullscreenToggle = document.getElementById('fullscreen-toggle-' + instanceId);
|
180 |
+
const helpToggle = document.getElementById('help-toggle-' + instanceId);
|
181 |
+
const resetCameraBtn = document.getElementById('reset-camera-btn-' + instanceId);
|
182 |
+
const menuContent = document.getElementById('menu-content-' + instanceId);
|
183 |
+
const canvas = document.getElementById('canvas-' + instanceId);
|
184 |
+
const progressDialog = document.getElementById('progress-dialog-' + instanceId);
|
185 |
+
const progressIndicator = document.getElementById('progress-indicator-' + instanceId);
|
186 |
+
|
187 |
+
// Flag to track if mouse is over the viewer
|
188 |
+
let isMouseOverViewer = false;
|
189 |
+
|
190 |
+
// Add mouse hover tracking for the viewer container
|
191 |
+
viewerContainer.addEventListener('mouseenter', function() {
|
192 |
+
isMouseOverViewer = true;
|
193 |
+
});
|
194 |
+
|
195 |
+
viewerContainer.addEventListener('mouseleave', function() {
|
196 |
+
isMouseOverViewer = false;
|
197 |
+
});
|
198 |
+
|
199 |
+
// Set help instructions based on device type.
|
200 |
+
if (isMobile) {
|
201 |
+
menuContent.innerHTML = `
|
202 |
+
- Pour vous déplacer, glissez deux doigts sur l'écran.<br>
|
203 |
+
- Pour orbiter, utilisez un doigt.<br>
|
204 |
+
- Pour zoomer, pincez avec deux doigts.
|
205 |
+
`;
|
206 |
+
} else {
|
207 |
+
menuContent.innerHTML = `
|
208 |
+
- orbitez avec le clic droit<br>
|
209 |
+
- zoomez avec la molette<br>
|
210 |
+
- déplacez vous avec le clic gauche
|
211 |
+
`;
|
212 |
+
}
|
213 |
+
|
214 |
+
// Handle GIF configuration
|
215 |
+
if (gifUrl) {
|
216 |
+
previewImage.src = gifUrl;
|
217 |
+
gifPreview.style.display = 'block';
|
218 |
+
viewerContainer.style.display = 'none';
|
219 |
+
} else {
|
220 |
+
gifPreview.style.display = 'none';
|
221 |
+
viewerContainer.style.display = 'block';
|
222 |
+
closeBtn.style.display = 'none';
|
223 |
+
// Initialize the viewer only when needed
|
224 |
+
setTimeout(() => {
|
225 |
+
initializeViewer();
|
226 |
+
}, 100);
|
227 |
+
}
|
228 |
+
|
229 |
+
// --- Button Event Handlers ---
|
230 |
+
if (gifUrl) {
|
231 |
+
// Add click event to the GIF container
|
232 |
+
gifPreview.addEventListener('click', function() {
|
233 |
+
console.log("GIF preview clicked, showing 3D viewer");
|
234 |
+
gifPreview.style.display = 'none';
|
235 |
+
viewerContainer.style.display = 'block';
|
236 |
+
|
237 |
+
// Make sure we're not initializing again if already done
|
238 |
+
if (!viewerInitialized) {
|
239 |
+
initializeViewer();
|
240 |
+
}
|
241 |
+
});
|
242 |
}
|
243 |
+
|
244 |
+
// Function to clean up the viewer completely
|
245 |
+
function cleanupViewer() {
|
246 |
+
console.log("Starting viewer cleanup...");
|
247 |
+
|
248 |
+
// Clear any running intervals
|
249 |
+
if (progressChecker) {
|
250 |
+
clearInterval(progressChecker);
|
251 |
+
progressChecker = null;
|
252 |
+
}
|
253 |
+
|
254 |
+
// Remove wheel event listeners
|
255 |
+
for (const handler of wheelHandlers) {
|
256 |
+
const [element, func] = handler;
|
257 |
+
element.removeEventListener('wheel', func, { passive: false });
|
258 |
+
}
|
259 |
+
wheelHandlers = [];
|
260 |
+
|
261 |
+
// Remove resize handler if it exists
|
262 |
+
if (resizeHandler) {
|
263 |
+
window.removeEventListener('resize', resizeHandler);
|
264 |
+
resizeHandler = null;
|
265 |
+
}
|
266 |
+
|
267 |
+
// Reset the canvas context
|
268 |
+
if (canvas) {
|
269 |
+
const ctx = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
270 |
+
if (ctx && ctx.getExtension('WEBGL_lose_context')) {
|
271 |
+
try {
|
272 |
+
ctx.getExtension('WEBGL_lose_context').loseContext();
|
273 |
+
} catch (e) {
|
274 |
+
console.error("Error releasing WebGL context:", e);
|
275 |
+
}
|
276 |
+
}
|
277 |
+
}
|
278 |
+
|
279 |
+
// Destroy PlayCanvas app if it exists
|
280 |
+
if (app) {
|
281 |
+
try {
|
282 |
+
app.destroy();
|
283 |
+
} catch (e) {
|
284 |
+
console.error("Error destroying PlayCanvas app:", e);
|
285 |
+
}
|
286 |
+
app = null;
|
287 |
+
}
|
288 |
+
|
289 |
+
// Reset entities
|
290 |
+
cameraEntity = null;
|
291 |
+
modelEntity = null;
|
292 |
+
|
293 |
+
// Mark the viewer as not initialized
|
294 |
+
viewerInitialized = false;
|
295 |
+
|
296 |
+
console.log("Viewer cleanup complete");
|
297 |
+
}
|
298 |
+
|
299 |
+
// Close button event handler
|
300 |
+
closeBtn.addEventListener('click', function() {
|
301 |
+
console.log("Close button clicked");
|
302 |
+
|
303 |
+
// Handle fullscreen exit
|
304 |
+
if (document.fullscreenElement === widgetContainer) {
|
305 |
+
if (document.exitFullscreen) {
|
306 |
+
document.exitFullscreen();
|
307 |
+
}
|
308 |
+
}
|
309 |
+
if (widgetContainer.classList.contains('fake-fullscreen')) {
|
310 |
+
widgetContainer.classList.remove('fake-fullscreen');
|
311 |
+
fullscreenToggle.textContent = '⇱';
|
312 |
+
}
|
313 |
+
|
314 |
+
// Clean up the viewer
|
315 |
+
cleanupViewer();
|
316 |
+
|
317 |
+
// Hide viewer and show GIF
|
318 |
+
viewerContainer.style.display = 'none';
|
319 |
+
gifPreview.style.display = 'block';
|
320 |
+
});
|
321 |
+
|
322 |
+
// Fullscreen toggle handler
|
323 |
+
fullscreenToggle.addEventListener('click', function() {
|
324 |
+
if (isIOS) {
|
325 |
+
if (!widgetContainer.classList.contains('fake-fullscreen')) {
|
326 |
+
widgetContainer.classList.add('fake-fullscreen');
|
327 |
+
} else {
|
328 |
+
widgetContainer.classList.remove('fake-fullscreen');
|
329 |
+
resetCamera();
|
330 |
+
}
|
331 |
+
fullscreenToggle.textContent = widgetContainer.classList.contains('fake-fullscreen') ? '⇲' : '⇱';
|
332 |
+
} else {
|
333 |
+
if (!document.fullscreenElement) {
|
334 |
+
if (widgetContainer.requestFullscreen) {
|
335 |
+
widgetContainer.requestFullscreen();
|
336 |
+
} else if (widgetContainer.webkitRequestFullscreen) {
|
337 |
+
widgetContainer.webkitRequestFullscreen();
|
338 |
+
} else if (widgetContainer.mozRequestFullScreen) {
|
339 |
+
widgetContainer.mozRequestFullScreen();
|
340 |
+
} else if (widgetContainer.msRequestFullscreen) {
|
341 |
+
widgetContainer.msRequestFullscreen();
|
342 |
+
}
|
343 |
+
} else {
|
344 |
+
if (document.exitFullscreen) {
|
345 |
+
document.exitFullscreen();
|
346 |
+
}
|
347 |
+
}
|
348 |
+
}
|
349 |
+
});
|
350 |
+
|
351 |
+
// Listen for native fullscreen changes.
|
352 |
+
document.addEventListener('fullscreenchange', function() {
|
353 |
+
if (document.fullscreenElement === widgetContainer) {
|
354 |
+
fullscreenToggle.textContent = '⇲';
|
355 |
+
widgetContainer.style.height = '100%';
|
356 |
+
widgetContainer.style.paddingBottom = '0';
|
357 |
+
resetCamera();
|
358 |
+
} else {
|
359 |
+
fullscreenToggle.textContent = '⇱';
|
360 |
+
widgetContainer.style.height = '0';
|
361 |
+
widgetContainer.style.paddingBottom = aspectPercent;
|
362 |
+
resetCamera();
|
363 |
+
}
|
364 |
+
});
|
365 |
+
|
366 |
+
// Help toggle button
|
367 |
+
helpToggle.addEventListener('click', function(e) {
|
368 |
+
e.stopPropagation();
|
369 |
+
menuContent.style.display = (menuContent.style.display === 'block') ? 'none' : 'block';
|
370 |
+
});
|
371 |
+
|
372 |
+
// --- Camera Reset Function ---
|
373 |
+
function resetCamera() {
|
374 |
+
if (!cameraEntity || !modelEntity || !app) {
|
375 |
+
console.log("Cannot reset camera - missing entities or app");
|
376 |
+
return;
|
377 |
+
}
|
378 |
+
|
379 |
+
try {
|
380 |
+
// Get the orbit camera script
|
381 |
+
const orbitCam = cameraEntity.script.orbitCamera;
|
382 |
+
if (!orbitCam) {
|
383 |
+
console.log("Cannot reset camera - missing orbit camera script");
|
384 |
+
return;
|
385 |
+
}
|
386 |
+
|
387 |
+
console.log(`Resetting camera to: (${chosenCameraX}, ${chosenCameraY}, ${chosenCameraZ}) for ${isMobile ? 'mobile' : 'desktop'}`);
|
388 |
+
|
389 |
+
// Store model position
|
390 |
+
const modelPos = modelEntity.getPosition();
|
391 |
+
|
392 |
+
// 1. Create a temporary entity to help calculate new values
|
393 |
+
const tempEntity = new pc.Entity();
|
394 |
+
tempEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
|
395 |
+
tempEntity.lookAt(modelPos);
|
396 |
+
|
397 |
+
// 2. Calculate the distance between camera and model
|
398 |
+
const distance = new pc.Vec3().sub2(
|
399 |
+
new pc.Vec3(chosenCameraX, chosenCameraY, chosenCameraZ),
|
400 |
+
modelPos
|
401 |
+
).length();
|
402 |
+
|
403 |
+
// 3. Set camera position
|
404 |
+
cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
|
405 |
+
cameraEntity.lookAt(modelPos);
|
406 |
+
|
407 |
+
// 4. Update the orbit camera's pivot point
|
408 |
+
orbitCam.pivotPoint = new pc.Vec3(modelPos.x, modelPos.y, modelPos.z);
|
409 |
+
|
410 |
+
// 5. Set the distance
|
411 |
+
orbitCam._targetDistance = distance;
|
412 |
+
orbitCam._distance = distance;
|
413 |
+
|
414 |
+
// 6. Calculate and set yaw and pitch from the camera's rotation
|
415 |
+
const rotation = tempEntity.getRotation();
|
416 |
+
const tempForward = new pc.Vec3();
|
417 |
+
rotation.transformVector(pc.Vec3.FORWARD, tempForward);
|
418 |
+
|
419 |
+
const yaw = Math.atan2(-tempForward.x, -tempForward.z) * pc.math.RAD_TO_DEG;
|
420 |
+
|
421 |
+
const yawQuat = new pc.Quat().setFromEulerAngles(0, -yaw, 0);
|
422 |
+
const rotWithoutYaw = new pc.Quat().mul2(yawQuat, rotation);
|
423 |
+
const forwardWithoutYaw = new pc.Vec3();
|
424 |
+
rotWithoutYaw.transformVector(pc.Vec3.FORWARD, forwardWithoutYaw);
|
425 |
+
const pitch = Math.atan2(forwardWithoutYaw.y, -forwardWithoutYaw.z) * pc.math.RAD_TO_DEG;
|
426 |
+
|
427 |
+
// Set yaw and pitch directly on internal variables
|
428 |
+
orbitCam._targetYaw = yaw;
|
429 |
+
orbitCam._yaw = yaw;
|
430 |
+
orbitCam._targetPitch = pitch;
|
431 |
+
orbitCam._pitch = pitch;
|
432 |
+
|
433 |
+
// Force update
|
434 |
+
if (typeof orbitCam._updatePosition === 'function') {
|
435 |
+
orbitCam._updatePosition();
|
436 |
+
}
|
437 |
+
|
438 |
+
// Clean up
|
439 |
+
tempEntity.destroy();
|
440 |
+
|
441 |
+
console.log("Camera reset complete");
|
442 |
+
|
443 |
+
} catch (error) {
|
444 |
+
console.error("Error resetting camera:", error);
|
445 |
+
}
|
446 |
+
}
|
447 |
+
|
448 |
+
// Reset camera button event handler
|
449 |
+
resetCameraBtn.addEventListener('click', function() {
|
450 |
+
console.log("Reset camera button clicked");
|
451 |
+
resetCamera();
|
452 |
+
});
|
453 |
+
|
454 |
+
// Escape key handler for fullscreen exit
|
455 |
+
document.addEventListener('keydown', function(e) {
|
456 |
+
if (e.key === 'Escape' || e.key === 'Esc') {
|
457 |
+
let wasFullscreen = false;
|
458 |
+
if (document.fullscreenElement === widgetContainer) {
|
459 |
+
wasFullscreen = true;
|
460 |
+
if (document.exitFullscreen) {
|
461 |
+
document.exitFullscreen();
|
462 |
+
}
|
463 |
+
}
|
464 |
+
if (widgetContainer.classList.contains('fake-fullscreen')) {
|
465 |
+
wasFullscreen = true;
|
466 |
+
widgetContainer.classList.remove('fake-fullscreen');
|
467 |
+
fullscreenToggle.textContent = '⇱';
|
468 |
+
}
|
469 |
+
if (wasFullscreen) {
|
470 |
+
resetCamera();
|
471 |
+
}
|
472 |
+
}
|
473 |
+
});
|
474 |
+
|
475 |
+
// --- Prevent app from hijacking all wheel events ---
|
476 |
+
function setupWheelHandlers() {
|
477 |
+
// First remove any existing handlers
|
478 |
+
for (const handler of wheelHandlers) {
|
479 |
+
const [element, func] = handler;
|
480 |
+
element.removeEventListener('wheel', func, { passive: false });
|
481 |
+
}
|
482 |
+
wheelHandlers = [];
|
483 |
+
|
484 |
+
// Create new wheel handler
|
485 |
+
const handleWheel = function(event) {
|
486 |
+
// Check if mouse is over the viewer
|
487 |
+
if (!isMouseOverViewer) {
|
488 |
+
// Allow normal page scrolling
|
489 |
+
return true;
|
490 |
+
}
|
491 |
+
|
492 |
+
// Otherwise apply zooming, but prevent default only for viewer area
|
493 |
+
event.stopPropagation();
|
494 |
+
|
495 |
+
if (cameraEntity && cameraEntity.script && cameraEntity.script.orbitCamera) {
|
496 |
+
const camera = cameraEntity.camera;
|
497 |
+
const orbitCamera = cameraEntity.script.orbitCamera;
|
498 |
+
const sensitivity = cameraEntity.script.orbitCameraInputMouse ?
|
499 |
+
cameraEntity.script.orbitCameraInputMouse.distanceSensitivity || 0.4 : 0.4;
|
500 |
+
|
501 |
+
if (camera.projection === pc.PROJECTION_PERSPECTIVE) {
|
502 |
+
orbitCamera.distance -= event.deltaY * 0.01 * sensitivity * (orbitCamera.distance * 0.1);
|
503 |
+
} else {
|
504 |
+
orbitCamera.orthoHeight -= event.deltaY * 0.01 * sensitivity * (orbitCamera.orthoHeight * 0.1);
|
505 |
+
}
|
506 |
+
|
507 |
+
event.preventDefault();
|
508 |
+
}
|
509 |
+
};
|
510 |
+
|
511 |
+
// Add wheel handlers and store references for cleanup
|
512 |
+
viewerContainer.addEventListener('wheel', handleWheel, { passive: false });
|
513 |
+
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
514 |
+
|
515 |
+
// Store handlers for later cleanup
|
516 |
+
wheelHandlers.push([viewerContainer, handleWheel]);
|
517 |
+
wheelHandlers.push([canvas, handleWheel]);
|
518 |
+
|
519 |
+
console.log("Wheel handlers set up");
|
520 |
+
}
|
521 |
+
|
522 |
+
// --- Initialize the 3D PLY Viewer using PlayCanvas ---
|
523 |
+
async function initializeViewer() {
|
524 |
+
// Skip initialization if already initialized
|
525 |
+
if (viewerInitialized || app) {
|
526 |
+
console.log("Viewer already initialized or app exists, skipping initialization");
|
527 |
+
return;
|
528 |
+
}
|
529 |
+
|
530 |
+
console.log("Initializing PLY viewer...");
|
531 |
+
progressDialog.style.display = 'block';
|
532 |
+
progressIndicator.value = 0;
|
533 |
+
|
534 |
+
// Initialize PlayCanvas
|
535 |
+
const deviceType = "webgl2";
|
536 |
+
const gfxOptions = {
|
537 |
+
deviceTypes: [deviceType],
|
538 |
+
glslangUrl: `https://playcanvas.vercel.app/static/lib/glslang/glslang.js`,
|
539 |
+
twgslUrl: `https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js`,
|
540 |
+
antialias: false
|
541 |
+
};
|
542 |
+
|
543 |
+
try {
|
544 |
+
// Create graphics device
|
545 |
+
const device = await pc.createGraphicsDevice(canvas, gfxOptions);
|
546 |
+
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
|
547 |
+
|
548 |
+
// Create app
|
549 |
+
const createOptions = new pc.AppOptions();
|
550 |
+
createOptions.graphicsDevice = device;
|
551 |
+
createOptions.mouse = new pc.Mouse(canvas);
|
552 |
+
createOptions.touch = new pc.TouchDevice(canvas);
|
553 |
+
createOptions.componentSystems = [
|
554 |
+
pc.RenderComponentSystem,
|
555 |
+
pc.CameraComponentSystem,
|
556 |
+
pc.LightComponentSystem,
|
557 |
+
pc.ScriptComponentSystem,
|
558 |
+
pc.GSplatComponentSystem
|
559 |
+
];
|
560 |
+
createOptions.resourceHandlers = [
|
561 |
+
pc.TextureHandler,
|
562 |
+
pc.ContainerHandler,
|
563 |
+
pc.ScriptHandler,
|
564 |
+
pc.GSplatHandler
|
565 |
+
];
|
566 |
+
|
567 |
+
app = new pc.AppBase(canvas);
|
568 |
+
app.init(createOptions);
|
569 |
+
|
570 |
+
// Set canvas fill mode to match the container
|
571 |
+
app.setCanvasFillMode(pc.FILLMODE_NONE);
|
572 |
+
app.setCanvasResolution(pc.RESOLUTION_AUTO);
|
573 |
+
|
574 |
+
// Set scene options
|
575 |
+
app.scene.exposure = 0.8;
|
576 |
+
app.scene.toneMapping = pc.TONEMAP_ACES;
|
577 |
+
|
578 |
+
// Handle window resizing
|
579 |
+
const resize = () => {
|
580 |
+
if (app) {
|
581 |
+
app.resizeCanvas(canvas.clientWidth, canvas.clientHeight);
|
582 |
+
}
|
583 |
+
};
|
584 |
+
|
585 |
+
// Store resize handler for cleanup
|
586 |
+
resizeHandler = resize;
|
587 |
+
window.addEventListener('resize', resizeHandler);
|
588 |
+
|
589 |
+
// Add cleanup when app is destroyed
|
590 |
+
app.on('destroy', () => {
|
591 |
+
if (resizeHandler) {
|
592 |
+
window.removeEventListener('resize', resizeHandler);
|
593 |
+
resizeHandler = null;
|
594 |
+
}
|
595 |
+
if (progressChecker) {
|
596 |
+
clearInterval(progressChecker);
|
597 |
+
progressChecker = null;
|
598 |
+
}
|
599 |
+
});
|
600 |
+
|
601 |
+
// Load required assets
|
602 |
+
const assets = {
|
603 |
+
model: new pc.Asset('gsplat', 'gsplat', { url: plyUrl }),
|
604 |
+
orbit: new pc.Asset('script', 'script', { url: `https://bilca-visionneur-play-canva-2.static.hf.space/orbit-camera.js` })
|
605 |
+
};
|
606 |
+
|
607 |
+
// Create asset loader with progress tracking
|
608 |
+
const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets);
|
609 |
+
|
610 |
+
// Handle asset loading progress
|
611 |
+
let lastProgress = 0;
|
612 |
+
assets.model.on('load', (asset) => {
|
613 |
+
progressDialog.style.display = 'none';
|
614 |
+
console.log("Model loaded successfully");
|
615 |
+
});
|
616 |
+
|
617 |
+
assets.model.on('error', (err) => {
|
618 |
+
console.error("Error loading PLY file:", err);
|
619 |
+
progressDialog.innerHTML = `<p style="color: red">Error loading model: ${err}</p>`;
|
620 |
+
viewerInitialized = false;
|
621 |
+
});
|
622 |
+
|
623 |
+
// Set up progress monitoring
|
624 |
+
const checkProgress = () => {
|
625 |
+
if (!app) {
|
626 |
+
clearInterval(progressChecker);
|
627 |
+
progressChecker = null;
|
628 |
+
return;
|
629 |
+
}
|
630 |
+
|
631 |
+
if (app && assets.model.resource) {
|
632 |
+
progressIndicator.value = 100;
|
633 |
+
clearInterval(progressChecker);
|
634 |
+
progressChecker = null;
|
635 |
+
progressDialog.style.display = 'none';
|
636 |
+
} else if (assets.model.loading) {
|
637 |
+
// Increment progress for visual feedback
|
638 |
+
lastProgress += 2;
|
639 |
+
if (lastProgress > 90) lastProgress = 90; // Cap at 90% until fully loaded
|
640 |
+
progressIndicator.value = lastProgress;
|
641 |
+
}
|
642 |
+
};
|
643 |
+
|
644 |
+
progressChecker = setInterval(checkProgress, 100);
|
645 |
+
|
646 |
+
// Load assets and set up scene
|
647 |
+
assetListLoader.load(() => {
|
648 |
+
if (!app) {
|
649 |
+
console.log("App was destroyed during asset loading");
|
650 |
+
return;
|
651 |
+
}
|
652 |
+
|
653 |
+
app.start();
|
654 |
+
|
655 |
+
// Create model entity
|
656 |
+
modelEntity = new pc.Entity('model');
|
657 |
+
modelEntity.addComponent('gsplat', {
|
658 |
+
asset: assets.model
|
659 |
+
});
|
660 |
+
|
661 |
+
// Position the model using JSON parameters
|
662 |
+
modelEntity.setLocalPosition(modelX, modelY, modelZ);
|
663 |
+
modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ);
|
664 |
+
modelEntity.setLocalScale(modelScale, modelScale, modelScale);
|
665 |
+
|
666 |
+
app.root.addChild(modelEntity);
|
667 |
+
|
668 |
+
// Create camera entity
|
669 |
+
cameraEntity = new pc.Entity('camera');
|
670 |
+
cameraEntity.addComponent('camera', {
|
671 |
+
clearColor: new pc.Color(
|
672 |
+
config.canvas_background ? parseInt(config.canvas_background.substr(1, 2), 16) / 255 : 0,
|
673 |
+
config.canvas_background ? parseInt(config.canvas_background.substr(3, 2), 16) / 255 : 0,
|
674 |
+
config.canvas_background ? parseInt(config.canvas_background.substr(5, 2), 16) / 255 : 0
|
675 |
+
),
|
676 |
+
toneMapping: pc.TONEMAP_ACES
|
677 |
+
});
|
678 |
+
|
679 |
+
// Set camera position directly using X, Y, Z coordinates from config
|
680 |
+
// Log the chosen camera position for debugging
|
681 |
+
console.log(`Setting camera position for ${isMobile ? 'mobile' : 'desktop'}: (${chosenCameraX}, ${chosenCameraY}, ${chosenCameraZ})`);
|
682 |
+
|
683 |
+
cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
|
684 |
+
cameraEntity.lookAt(modelEntity.getPosition());
|
685 |
+
|
686 |
+
// Add orbit camera script for interactive navigation
|
687 |
+
cameraEntity.addComponent('script');
|
688 |
+
cameraEntity.script.create('orbitCamera', {
|
689 |
+
attributes: {
|
690 |
+
inertiaFactor: 0.2,
|
691 |
+
focusEntity: modelEntity,
|
692 |
+
distanceMax: maxZoom,
|
693 |
+
distanceMin: minZoom,
|
694 |
+
pitchAngleMax: maxAngle,
|
695 |
+
pitchAngleMin: minAngle,
|
696 |
+
yawAngleMax: maxAzimuth,
|
697 |
+
yawAngleMin: minAzimuth,
|
698 |
+
frameOnStart: false // Don't auto-frame since we're setting position directly
|
699 |
+
}
|
700 |
+
});
|
701 |
+
|
702 |
+
// Create input controllers but don't add mouse wheel handling - we handle that separately
|
703 |
+
cameraEntity.script.create('orbitCameraInputMouse', {
|
704 |
+
attributes: {
|
705 |
+
orbitSensitivity: isMobile ? 0.6 : 0.3,
|
706 |
+
distanceSensitivity: isMobile ? 0.5 : 0.4
|
707 |
+
}
|
708 |
+
});
|
709 |
+
|
710 |
+
// Disable wheel event in the orbit camera input
|
711 |
+
if (cameraEntity.script.orbitCameraInputMouse) {
|
712 |
+
// Override mouse wheel to do nothing - we handle wheel events separately
|
713 |
+
cameraEntity.script.orbitCameraInputMouse.onMouseWheel = function() {};
|
714 |
+
}
|
715 |
+
|
716 |
+
// Add touch input controller
|
717 |
+
cameraEntity.script.create('orbitCameraInputTouch', {
|
718 |
+
attributes: {
|
719 |
+
orbitSensitivity: 0.6,
|
720 |
+
distanceSensitivity: 0.5
|
721 |
+
}
|
722 |
+
});
|
723 |
+
|
724 |
+
// Initialize the orbit controller
|
725 |
+
setTimeout(() => {
|
726 |
+
if (cameraEntity && cameraEntity.script && cameraEntity.script.orbitCamera) {
|
727 |
+
// Calculate distance from camera to model
|
728 |
+
const modelPos = modelEntity.getPosition();
|
729 |
+
const camPos = cameraEntity.getPosition();
|
730 |
+
const distanceVec = new pc.Vec3();
|
731 |
+
distanceVec.sub2(camPos, modelPos);
|
732 |
+
const distance = distanceVec.length();
|
733 |
+
|
734 |
+
// Set up the orbit controller
|
735 |
+
cameraEntity.script.orbitCamera.pivotPoint.copy(modelPos);
|
736 |
+
cameraEntity.script.orbitCamera.distance = distance;
|
737 |
+
cameraEntity.script.orbitCamera._removeInertia();
|
738 |
+
}
|
739 |
+
}, 100);
|
740 |
+
|
741 |
+
app.root.addChild(cameraEntity);
|
742 |
+
|
743 |
+
// Initial resize to match container
|
744 |
+
resize();
|
745 |
+
|
746 |
+
// Set up wheel handlers
|
747 |
+
setupWheelHandlers();
|
748 |
+
|
749 |
+
// Hide progress dialog when everything is set up
|
750 |
+
progressDialog.style.display = 'none';
|
751 |
+
|
752 |
+
// Mark viewer as initialized
|
753 |
+
viewerInitialized = true;
|
754 |
+
|
755 |
+
console.log("PLY viewer initialization complete");
|
756 |
+
});
|
757 |
+
|
758 |
+
} catch (error) {
|
759 |
+
console.error("Error initializing PlayCanvas viewer:", error);
|
760 |
+
progressDialog.innerHTML = `<p style="color: red">Error loading viewer: ${error.message}</p>`;
|
761 |
+
viewerInitialized = false;
|
762 |
+
app = null;
|