MikaFil commited on
Commit
f371f1f
·
verified ·
1 Parent(s): 69541fa

Update deplacement_dans_env/viewer_pr_env.js

Browse files
Files changed (1) hide show
  1. deplacement_dans_env/viewer_pr_env.js +455 -409
deplacement_dans_env/viewer_pr_env.js CHANGED
@@ -1,69 +1,43 @@
1
- // viewer_pr_env.js — robuste v2 (multi‑CDN PlayCanvas import)
2
- // ============================================================================
3
- // - Boot non-bloquant : l'app démarre tout de suite (caméra au root)
4
- // - Import PlayCanvas **robuste** : ESM multi‑CDN (esm.run → jsDelivr → unpkg)
5
- // puis fallback **UMD** (playcanvas.js) si ESM bloqué par CSP/iframe
6
- // - Ammo (WASM) en parallèle, timeout + double API (promise/callback)
7
- // - Colliders mesh ajoutés après 1ʳᵉ frame (postrender)
8
- // - Player capsule créé seulement si Ammo OK, puis caméra reparentée
9
- // - GSplat/GLB chargés avec logs + timeout ; DPR dynamique ; logs jalons
10
- // - Ne charge pas le script de contrôle caméra (à ajouter côté scène)
11
- // ============================================================================
12
 
13
  /* -------------------------------------------
14
- Error surface (avant tout)
15
- -------------------------------------------- */
16
- window.addEventListener('error', (e) => {
17
- console.error('[BOOT] Uncaught error:', e.message || e.error, e);
18
- });
19
- window.addEventListener('unhandledrejection', (e) => {
20
- console.error('[BOOT] Unhandled promise rejection:', e.reason);
21
- });
22
- console.log('[BOOT] JS boot reached');
23
-
24
- /* -------------------------------------------
25
- State (module / instance)
26
  -------------------------------------------- */
27
- let pc;
28
- export let app = null;
29
- export let cameraEntity = null;
30
- export let playerEntity = null; // capsule dynamique (rigidbody)
31
- export let envEntity = null; // GLB (environnement)
32
- export let modelEntity = null; // GSplat principal (.sog)
33
-
34
- let viewerInitialized = false;
35
- let resizeObserver = null;
36
- let resizeTimeout = null;
37
 
38
- // Config / paramètres courants
39
- let sogUrl, glbUrl, presentoirUrl;
40
- let color_bg_hex, color_bg;
41
- let espace_expo_bool;
42
-
43
- // Camera spawn
44
- let chosenCameraX, chosenCameraY, chosenCameraZ;
45
-
46
- // DPR / perf
47
- let maxDevicePixelRatio = 1.75;
48
- let interactDpr = 1.0;
49
- let idleRestoreDelay = 350;
50
- let idleTimer = null;
51
-
52
- // Physique
53
- let ammoBaseUrl = 'https://playcanvas.github.io/examples/lib/ammo/'; // surchargable par config
54
- let wantPhysics = true; // souhait ; peut être désactivé si Ammo KO
55
- let gravityVec = null; // défini via config
56
 
57
- /* -------------------------------------------
58
- Utils
59
- -------------------------------------------- */
60
- const stamp = () => new Date().toISOString().slice(11,19);
61
- const step = (label, extra) => console.log(`[${stamp()}] [VIEWER] ${label}`, extra ?? '');
 
 
 
 
62
 
63
  function hexToRgbaArray(hex) {
64
  try {
65
- hex = String(hex || '').replace('#', '');
66
- if (hex.length === 6) hex += 'FF';
67
  if (hex.length !== 8) return [1, 1, 1, 1];
68
  const num = parseInt(hex, 16);
69
  return [
@@ -73,151 +47,137 @@ function hexToRgbaArray(hex) {
73
  (num & 0xff) / 255
74
  ];
75
  } catch (e) {
76
- console.warn('[viewer] hexToRgbaArray error:', e);
77
  return [1, 1, 1, 1];
78
  }
79
  }
80
 
81
- function traverse(entity, cb) {
82
- cb(entity);
83
- if (entity.children && entity.children.length) {
84
- entity.children.forEach((c) => traverse(c, cb));
 
85
  }
86
  }
87
 
88
- function isMobileUA() {
89
- const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
90
- const isAndroid = /Android/i.test(navigator.userAgent);
91
- return isIOS || isAndroid;
92
- }
93
-
94
- async function importPlayCanvasRobust(timeoutMs = 6000) {
95
- const sourcesESM = [
96
- 'https://esm.run/playcanvas',
97
- 'https://cdn.jsdelivr.net/npm/playcanvas@latest/build/playcanvas.mjs',
98
- 'https://unpkg.com/playcanvas@latest/build/playcanvas.mjs'
99
- ];
100
 
101
- // Essais ESM
102
- for (const url of sourcesESM) {
103
- try {
104
- console.log('[PC-IMPORT] Try ESM:', url);
105
- const mod = await Promise.race([
106
- import(/* @vite-ignore */ url),
107
- new Promise((_, rej) => setTimeout(() => rej(new Error('ESM timeout')), timeoutMs))
108
- ]);
109
- if (mod) {
110
- console.log('[PC-IMPORT] ESM OK:', url);
111
- return mod; // module ESM
112
- }
113
- } catch (e) {
114
- console.warn('[PC-IMPORT] ESM failed:', url, e);
115
- }
116
  }
117
 
118
- // Fallback UMD
119
- const sourcesUMD = [
120
- 'https://cdn.jsdelivr.net/npm/playcanvas@latest/build/playcanvas.js',
121
- 'https://unpkg.com/playcanvas@latest/build/playcanvas.js'
122
- ];
123
- for (const url of sourcesUMD) {
124
- try {
125
- console.log('[PC-IMPORT] Try UMD:', url);
126
- await new Promise((resolve, reject) => {
127
- const s = document.createElement('script');
128
- s.src = url; s.async = true; s.onload = resolve; s.onerror = reject;
129
- document.head.appendChild(s);
130
- });
131
- if (window.pc) { console.log('[PC-IMPORT] UMD OK:', url); return window.pc; }
132
- } catch (e) {
133
- console.warn('[PC-IMPORT] UMD failed:', url, e);
134
- }
135
- }
136
- throw new Error('PlayCanvas import failed on all sources');
137
- }
138
 
139
- async function loadAmmoWithTimeout(baseUrl, timeoutMs = 4000) {
140
- try {
141
- pc.WasmModule.setConfig('Ammo', {
142
- glueUrl: `${baseUrl}ammo.wasm.js`,
143
- wasmUrl: `${baseUrl}ammo.wasm.wasm`,
144
- fallbackUrl: `${baseUrl}ammo.js`
145
- });
146
- } catch (e) {
147
- console.warn('[Ammo] setConfig failed (ignored):', e);
148
- }
149
 
150
- // 1) API Promise
151
- try {
152
- const maybePromise = pc.WasmModule.getInstance('Ammo', `${baseUrl}ammo.wasm.js`);
153
- if (maybePromise && typeof maybePromise.then === 'function') {
154
- step('Ammo: waiting (promise API)…');
155
- await Promise.race([
156
- maybePromise,
157
- new Promise((_, rej) => setTimeout(() => rej(new Error('Ammo load timeout (promise)')), timeoutMs))
158
- ]);
159
- step('Ammo: ready (promise API)');
160
- return true;
161
- }
162
- } catch (e) {
163
- console.warn('[Ammo] promise API failed:', e);
164
- }
165
 
166
- // 2) API callback
167
- let resolved = false;
168
- try {
169
- step('Ammo: waiting (callback API)…');
170
- await Promise.race([
171
- new Promise((resolve) => pc.WasmModule.getInstance('Ammo', () => { resolved = true; resolve(); })),
172
- new Promise((_, rej) => setTimeout(() => rej(new Error('Ammo load timeout (callback)')), timeoutMs))
173
- ]);
174
- if (resolved) { step('Ammo: ready (callback API)'); return true; }
175
- } catch (e) {
176
- console.warn('[Ammo] callback API failed:', e);
177
- }
178
 
179
- console.warn('[viewer] Ammo not available — physics disabled.');
180
- return false;
181
- }
 
 
 
 
182
 
183
- function safeGetEl(id, label) {
184
- const el = document.getElementById(id);
185
- if (!el) console.warn(`[viewer] Missing DOM #${id} (${label})`);
186
- return el;
187
- }
188
 
189
  /* -------------------------------------------
190
- Initialisation principale
191
  -------------------------------------------- */
192
- export async function initializeViewer(config, instanceId) {
193
- if (viewerInitialized) { console.warn('[viewer] initializeViewer called twice — ignored'); return; }
194
- step('A: initializeViewer begin', { instanceId });
195
-
196
- // ---- Lecture config ----
197
- const mobile = isMobileUA();
198
 
199
- sogUrl = config.sog_url || config.sogs_json_url || null;
200
- glbUrl = (config.glb_url !== undefined) ? config.glb_url : null;
201
- presentoirUrl = (config.presentoir_url !== undefined) ? config.presentoir_url : null;
202
 
203
- color_bg_hex = config.canvas_background !== undefined ? config.canvas_background : '#FFFFFF';
204
- espace_expo_bool = config.espace_expo_bool !== undefined ? !!config.espace_expo_bool : false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  color_bg = hexToRgbaArray(color_bg_hex);
206
 
207
- // Camera spawn valeurs par défaut
208
- const camX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0;
209
- const camY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 1.6;
210
- const camZ = config.cameraZ !== undefined ? parseFloat(config.cameraZ) : 4.0;
211
 
212
- const camXPhone = config.cameraXPhone !== undefined ? parseFloat(config.cameraXPhone) : camX;
213
- const camYPhone = config.cameraYPhone !== undefined ? parseFloat(config.cameraYPhone) : camY;
214
- const camZPhone = config.cameraZPhone !== undefined ? parseFloat(config.cameraZPhone) : camZ * 1.5;
215
-
216
- chosenCameraX = mobile ? camXPhone : camX;
217
- chosenCameraY = mobile ? camYPhone : camY;
218
- chosenCameraZ = mobile ? camZPhone : camZ;
219
-
220
- // DPR / perf
221
  if (config.maxDevicePixelRatio !== undefined) {
222
  maxDevicePixelRatio = Math.max(0.75, parseFloat(config.maxDevicePixelRatio) || maxDevicePixelRatio);
223
  }
@@ -228,87 +188,119 @@ export async function initializeViewer(config, instanceId) {
228
  idleRestoreDelay = Math.max(120, parseInt(config.idleRestoreDelayMs, 10) || idleRestoreDelay);
229
  }
230
 
231
- // Physique (souhait)
232
- if (config.ammoBaseUrl) ammoBaseUrl = String(config.ammoBaseUrl).endsWith('/') ? config.ammoBaseUrl : (config.ammoBaseUrl + '/');
233
- wantPhysics = config.usePhysics === false ? false : true; // par défaut true
234
- const freeFly = !!config.freeFly; // si true => pas de gravité
235
- gravityVec = freeFly ? new pc.Vec3(0, 0, 0) : new pc.Vec3(0, -9.81, 0);
236
-
237
- // ---- Canvas / DOM ----
238
- const canvasId = 'canvas-' + instanceId;
239
- const progressId = 'progress-dialog-' + instanceId;
240
- const containerId = 'viewer-container-' + instanceId;
241
- const progressDialog = safeGetEl(progressId, 'progress');
242
- const viewerContainer = safeGetEl(containerId, 'container');
243
- if (!viewerContainer) throw new Error('Viewer container not found');
244
 
245
  const old = document.getElementById(canvasId);
246
  if (old) old.remove();
247
 
248
- const canvas = document.createElement('canvas');
249
  canvas.id = canvasId;
250
- canvas.className = 'ply-canvas';
251
- canvas.style.width = '100%';
252
- canvas.style.height = '100%';
253
- canvas.setAttribute('tabindex', '0');
254
- if (progressDialog) viewerContainer.insertBefore(canvas, progressDialog);
255
- else viewerContainer.appendChild(canvas);
256
-
257
- // Interaction UI de base (prévenir scroll)
258
- canvas.style.touchAction = 'none';
259
- canvas.style.webkitTouchCallout = 'none';
260
- canvas.addEventListener('gesturestart', (e) => e.preventDefault());
261
- canvas.addEventListener('gesturechange', (e) => e.preventDefault());
262
- canvas.addEventListener('gestureend', (e) => e.preventDefault());
263
- canvas.addEventListener('dblclick', (e) => e.preventDefault());
264
- canvas.addEventListener('wheel', (e) => e.preventDefault(), { passive: false });
265
-
266
- // Focus au survol pour capter le clavier
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  let isPointerOverCanvas = false;
268
  const focusCanvas = () => canvas.focus({ preventScroll: true });
269
- const onPointerEnter = () => { isPointerOverCanvas = true; focusCanvas(); };
270
- const onPointerLeave = () => { isPointerOverCanvas = false; if (document.activeElement === canvas) canvas.blur(); };
271
- const onCanvasBlur = () => { isPointerOverCanvas = false; };
272
-
273
- canvas.addEventListener('pointerenter', onPointerEnter);
274
- canvas.addEventListener('pointerleave', onPointerLeave);
275
- canvas.addEventListener('mouseenter', onPointerEnter);
276
- canvas.addEventListener('mouseleave', onPointerLeave);
277
- canvas.addEventListener('mousedown', focusCanvas);
278
- canvas.addEventListener('touchstart', focusCanvas, { passive: true });
279
- canvas.addEventListener('blur', onCanvasBlur);
280
-
281
- // Empêche les touches scroll pendant survol
282
- const scrollKeys = new Set(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','PageUp','PageDown','Home','End',' ','Space','Space','Spacebar']);
283
- const onKeyDownCapture = (e) => { if (!isPointerOverCanvas) return; if (scrollKeys.has(e.key) || scrollKeys.has(e.code)) e.preventDefault(); };
284
- window.addEventListener('keydown', onKeyDownCapture, true);
285
-
286
- if (progressDialog) progressDialog.style.display = 'block';
287
-
288
- // ---- Import PlayCanvas (robuste) ----
289
- if (!pc) {
290
- step('B: importing PlayCanvas (robust)…');
291
- try {
292
- const modOrPc = await importPlayCanvasRobust( mobile ? 8000 : 6000 );
293
- pc = modOrPc && modOrPc.Application ? modOrPc : (window.pc || modOrPc);
294
- window.pc = pc; // debug convenience
295
- step('B+: PlayCanvas imported OK');
296
- } catch (e) {
297
- console.error('[PC-IMPORT] FAILED:', e);
298
- throw e; // surface d’erreurs l’affichera
 
299
  }
 
 
 
 
 
 
 
 
 
300
  }
301
 
302
- // ---- Crée lApplication (démarrage non-bloquant) ----
303
- step('C: createGraphicsDevice');
304
- const device = await pc.createGraphicsDevice(canvas, { deviceTypes: ['webgl2', 'webgl1'], antialias: false });
 
 
 
 
 
 
305
  device.maxPixelRatio = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio);
306
 
307
  const opts = new pc.AppOptions();
308
  opts.graphicsDevice = device;
309
- opts.mouse = new pc.Mouse(canvas);
310
- opts.touch = new pc.TouchDevice(canvas);
311
- opts.keyboard= new pc.Keyboard(canvas);
312
  opts.componentSystems = [
313
  pc.RenderComponentSystem,
314
  pc.CameraComponentSystem,
@@ -318,31 +310,14 @@ export async function initializeViewer(config, instanceId) {
318
  pc.CollisionComponentSystem,
319
  pc.RigidbodyComponentSystem
320
  ];
 
321
  opts.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.ScriptHandler, pc.GSplatHandler];
322
 
323
  app = new pc.Application(canvas, opts);
324
  app.setCanvasFillMode(pc.FILLMODE_NONE);
325
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
326
- app.scene.gravity = gravityVec || new pc.Vec3(0, -9.81, 0);
327
 
328
- // Démarrer la boucle tout de suite (rendu garanti)
329
- step('D: app.start()');
330
- app.start();
331
- step('D+: app started');
332
-
333
- // Caméra fallback — toujours visible même si Ammo KO
334
- step('E: create fallback camera');
335
- cameraEntity = new pc.Entity('camera');
336
- cameraEntity.addComponent('camera', {
337
- clearColor: new pc.Color(color_bg),
338
- nearClip: 0.03,
339
- farClip: 500
340
- });
341
- cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
342
- app.root.addChild(cameraEntity);
343
- cameraEntity.lookAt(new pc.Vec3(0,1,0));
344
-
345
- // Resize observé (debounce)
346
  resizeObserver = new ResizeObserver((entries) => {
347
  if (!entries || !entries.length) return;
348
  if (resizeTimeout) clearTimeout(resizeTimeout);
@@ -352,111 +327,112 @@ export async function initializeViewer(config, instanceId) {
352
  });
353
  resizeObserver.observe(viewerContainer);
354
 
355
- window.addEventListener('resize', () => {
356
  if (resizeTimeout) clearTimeout(resizeTimeout);
357
  resizeTimeout = setTimeout(() => {
358
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
359
  }, 60);
360
  });
361
 
362
- app.on('destroy', () => {
363
- try { resizeObserver.disconnect(); } catch {}
 
 
 
364
  if (opts.keyboard && opts.keyboard.detach) opts.keyboard.detach();
365
- window.removeEventListener('keydown', onKeyDownCapture, true);
366
-
367
- canvas.removeEventListener('pointerenter', onPointerEnter);
368
- canvas.removeEventListener('pointerleave', onPointerLeave);
369
- canvas.removeEventListener('mouseenter', onPointerEnter);
370
- canvas.removeEventListener('mouseleave', onPointerLeave);
371
- canvas.removeEventListener('mousedown', focusCanvas);
372
- canvas.removeEventListener('touchstart', focusCanvas);
373
- canvas.removeEventListener('blur', onCanvasBlur);
374
  });
375
 
376
- // ---- Charger assets SOG + GLB (tolérant) ----
377
- const assets = [];
378
- let sogAsset = null, glbAsset = null;
 
 
379
 
380
- if (sogUrl) { sogAsset = new pc.Asset('gsplat', 'gsplat', { url: sogUrl }); app.assets.add(sogAsset); assets.push(sogAsset); }
381
- if (glbUrl) { glbAsset = new pc.Asset('glb', 'container', { url: glbUrl }); app.assets.add(glbAsset); assets.push(glbAsset); }
382
 
383
- step('F: requesting assets', { sog: !!sogUrl, glb: !!glbUrl });
384
-
385
- await new Promise((resolve) => {
386
- if (!assets.length) return resolve();
387
- const loader = new pc.AssetListLoader(assets, app.assets);
388
- let done = false;
389
- const finish = (label) => { if (!done) { done = true; step('F+: assets loaded (' + label + ')'); resolve(); } };
390
- loader.on('error', (e) => { console.warn('[viewer] Asset load error:', e); finish('with errors'); });
391
- loader.load(() => finish('ok'));
392
- setTimeout(() => { if (!done) { console.warn('[viewer] Asset load timeout — continuing'); finish('timeout'); } }, 10000);
393
  });
394
 
395
- if (progressDialog) { progressDialog.style.display = 'none'; step('G: progress hidden'); }
396
-
397
- // ---- Crée le modèle GSplat (optionnel) ----
398
- if (sogAsset) {
399
- modelEntity = new pc.Entity('model');
400
- modelEntity.addComponent('gsplat', { asset: sogAsset });
401
- app.root.addChild(modelEntity);
402
- step('H: gsplat entity created');
403
- }
404
-
405
- // ---- Instancier le GLB d’environnement ----
406
- envEntity = glbAsset && glbAsset.resource ? glbAsset.resource.instantiateRenderEntity() : null;
407
- if (envEntity) { envEntity.name = 'ENV_GLTF'; app.root.addChild(envEntity); step('I: env GLB instanced'); }
408
- else { console.warn('[viewer] No environment GLB — physics will be skipped.'); }
409
-
410
- // ---- Matériau "fond uni" si pas d'espace expo ----
411
- if (!espace_expo_bool && envEntity) {
412
- const matSol = new pc.StandardMaterial();
413
- matSol.blendType = pc.BLEND_NONE;
414
- matSol.emissive = new pc.Color(color_bg);
415
- matSol.emissiveIntensity = 1;
416
- matSol.useLighting = false;
417
- matSol.update();
418
- traverse(envEntity, (node) => {
419
- if (node.render && node.render.meshInstances) {
420
- for (const mi of node.render.meshInstances) mi.material = matSol;
421
- }
422
- });
423
- step('I+: env recolored (flat)');
424
- }
425
-
426
- // ---- Physique : chargement Ammo en parallèle, colliders après 1ère frame ----
427
- if (wantPhysics) {
428
- (async () => {
429
- try {
430
- const ok = await loadAmmoWithTimeout(ammoBaseUrl, isMobileUA() ? 8000 : 5000).catch(() => false);
431
- if (!ok) { step('P: Ammo unavailable — rendering without physics'); return; }
432
-
433
- if (envEntity) {
434
- app.once('postrender', () => {
435
- const applyStaticMesh = (e) => {
436
- if (e.render && !e.collision) { try { e.addComponent('collision', { type: 'mesh' }); } catch (err) { console.warn('[phys] add collision failed:', err); } }
437
- if (!e.rigidbody) { try { e.addComponent('rigidbody', { type: 'static', friction: 0.6, restitution: 0.0 }); } catch (err) { console.warn('[phys] add rigidbody failed:', err); } }
438
- else if (e.rigidbody && e.rigidbody.type !== 'static') { e.rigidbody.type = 'static'; }
439
- };
440
- const stack=[envEntity];
441
- while (stack.length){ const n=stack.pop(); applyStaticMesh(n); n.children?.forEach(c=>stack.push(c)); }
442
- step('P+: static mesh colliders ready');
443
- });
444
- }
445
-
446
- createPlayerCapsuleAndAttachCamera(app, config);
447
- step('Q: physics ON (player created)');
448
- } catch (e) {
449
- console.warn('[phys] init failed:', e);
450
- }
451
- })();
452
  } else {
453
- step('P: physics disabled by config (usePhysics=false)');
454
  }
455
 
456
- // Ajuster taille initiale
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
458
 
459
- // DPR dynamique : réduit pendant interaction
 
 
 
460
  const setDpr = (val) => {
461
  const clamped = Math.max(0.5, Math.min(val, maxDevicePixelRatio));
462
  if (app.graphicsDevice.maxPixelRatio !== clamped) {
@@ -464,6 +440,7 @@ export async function initializeViewer(config, instanceId) {
464
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
465
  }
466
  };
 
467
  const bumpInteraction = () => {
468
  setDpr(interactDpr);
469
  if (idleTimer) clearTimeout(idleTimer);
@@ -471,73 +448,142 @@ export async function initializeViewer(config, instanceId) {
471
  setDpr(Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio));
472
  }, idleRestoreDelay);
473
  };
474
- ['mousedown', 'mousemove', 'mouseup', 'wheel', 'touchstart', 'touchmove', 'keydown']
475
- .forEach((ev) => canvas.addEventListener(ev, bumpInteraction, { passive: true }));
476
 
477
- viewerInitialized = true;
478
- step('Z: initializeViewer done', { physicsRequested: wantPhysics });
479
- }
 
480
 
481
- /* -------------------------------------------
482
- Helpers: création Player capsule + cam
483
- -------------------------------------------- */
484
- function createPlayerCapsuleAndAttachCamera(app, config) {
485
- if (playerEntity && playerEntity.rigidbody) { step('Q+: player already exists'); return; }
 
 
 
 
486
 
487
- playerEntity = new pc.Entity('Player');
488
- const capsuleRadius = config.capsuleRadius !== undefined ? parseFloat(config.capsuleRadius) : 0.30;
489
- const capsuleHeight = config.capsuleHeight !== undefined ? parseFloat(config.capsuleHeight) : 1.60;
 
 
 
490
 
491
- try { playerEntity.addComponent('collision', { type: 'capsule', radius: capsuleRadius, height: capsuleHeight }); }
492
- catch (e) { console.warn('[phys] add capsule collision failed:', e); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
 
494
- try {
495
- playerEntity.addComponent('rigidbody', {
496
- type: 'dynamic',
497
- mass: 70,
498
- friction: 0.45,
499
- restitution: 0.0,
500
- linearDamping: 0.15,
501
- angularDamping: 0.999
502
- });
503
- playerEntity.rigidbody.angularFactor = new pc.Vec3(0, 0, 0);
504
- } catch (e) { console.warn('[phys] add rigidbody failed:', e); }
505
-
506
- playerEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
507
- app.root.addChild(playerEntity);
508
-
509
- const eyes = config.eyesOffsetY !== undefined ? parseFloat(config.eyesOffsetY) : Math.max(0.1, capsuleHeight * 0.9);
510
- try {
511
- cameraEntity.reparent(playerEntity);
512
- cameraEntity.setLocalPosition(0, eyes, 0);
513
- cameraEntity.setLocalEulerAngles(0, 0, 0);
514
- } catch (e) { console.warn('[phys] camera reparent failed:', e); }
 
 
 
 
 
 
 
 
 
515
 
516
- const freeFly = !!config.freeFly;
517
- app.scene.gravity = freeFly ? new pc.Vec3(0,0,0) : new pc.Vec3(0,-9.81,0);
518
  }
519
 
520
  /* -------------------------------------------
521
- API helper : repositionner caméra/joueur
522
  -------------------------------------------- */
523
- export function resetViewerCamera(x, y, z) {
524
- if (!app || !cameraEntity) { console.warn('[viewer] resetViewerCamera: app/camera missing'); return; }
525
- const nx = (x !== undefined) ? parseFloat(x) : chosenCameraX;
526
- const ny = (y !== undefined) ? parseFloat(y) : chosenCameraY;
527
- const nz = (z !== undefined) ? parseFloat(z) : chosenCameraZ;
528
 
529
- if (playerEntity && playerEntity.rigidbody) {
530
- try {
531
- playerEntity.rigidbody.teleport(nx, ny, nz, playerEntity.getRotation());
532
- playerEntity.rigidbody.linearVelocity = pc.Vec3.ZERO.clone();
533
- playerEntity.rigidbody.angularVelocity = pc.Vec3.ZERO.clone();
534
- step('resetViewerCamera: teleported player', { nx, ny, nz });
535
- } catch (e) {
536
- console.warn('[viewer] teleport failed, fallback to camera setPosition:', e);
537
- cameraEntity.setPosition(nx, ny, nz);
 
 
 
 
 
 
 
 
 
 
538
  }
539
- } else {
540
- cameraEntity.setPosition(nx, ny, nz);
541
- step('resetViewerCamera: camera moved (no player)', { nx, ny, nz });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
  }
543
  }
 
1
+ // viewer.js
2
+ // ==============================
 
 
 
 
 
 
 
 
 
3
 
4
  /* -------------------------------------------
5
+ Utils
 
 
 
 
 
 
 
 
 
 
 
6
  -------------------------------------------- */
 
 
 
 
 
 
 
 
 
 
7
 
8
+ // (Conservé pour compat, même si .sog n'en a pas besoin)
9
+ async function loadImageAsTexture(url, app) {
10
+ return new Promise((resolve, reject) => {
11
+ const img = new window.Image();
12
+ img.crossOrigin = "anonymous";
13
+ img.onload = function () {
14
+ const tex = new pc.Texture(app.graphicsDevice, {
15
+ width: img.width,
16
+ height: img.height,
17
+ format: pc.PIXELFORMAT_R8_G8_B8_A8
18
+ });
19
+ tex.setSource(img);
20
+ resolve(tex);
21
+ };
22
+ img.onerror = reject;
23
+ img.src = url;
24
+ });
25
+ }
26
 
27
+ // Patch global Image -> force CORS
28
+ (function () {
29
+ const OriginalImage = window.Image;
30
+ window.Image = function (...args) {
31
+ const img = new OriginalImage(...args);
32
+ img.crossOrigin = "anonymous";
33
+ return img;
34
+ };
35
+ })();
36
 
37
  function hexToRgbaArray(hex) {
38
  try {
39
+ hex = String(hex || "").replace("#", "");
40
+ if (hex.length === 6) hex += "FF";
41
  if (hex.length !== 8) return [1, 1, 1, 1];
42
  const num = parseInt(hex, 16);
43
  return [
 
47
  (num & 0xff) / 255
48
  ];
49
  } catch (e) {
50
+ console.warn("hexToRgbaArray error:", e);
51
  return [1, 1, 1, 1];
52
  }
53
  }
54
 
55
+ // Parcours récursif d'une hiérarchie d'entités
56
+ function traverse(entity, callback) {
57
+ callback(entity);
58
+ if (entity.children) {
59
+ entity.children.forEach((child) => traverse(child, callback));
60
  }
61
  }
62
 
63
+ /* -------------------------------------------
64
+ Chargement unique de ctrl_camera_pr_env.js
65
+ -------------------------------------------- */
 
 
 
 
 
 
 
 
 
66
 
67
+ async function ensureOrbitScriptsLoaded() {
68
+ if (window.__PLY_ORBIT_LOADED__) return;
69
+ if (window.__PLY_ORBIT_LOADING__) {
70
+ await window.__PLY_ORBIT_LOADING__;
71
+ return;
 
 
 
 
 
 
 
 
 
 
72
  }
73
 
74
+ window.__PLY_ORBIT_LOADING__ = new Promise((resolve, reject) => {
75
+ const s = document.createElement("script");
76
+ // IMPORTANT : charger le script free-camera + collisions (pas l'ancienne orbit-camera)
77
+ s.src = "https://mikafil-viewer-sgos.static.hf.space/deplacement_dans_env/ctrl_camera_pr_env.js";
78
+ s.async = true;
79
+ s.onload = () => {
80
+ window.__PLY_ORBIT_LOADED__ = true;
81
+ resolve();
82
+ };
83
+ s.onerror = (e) => {
84
+ console.error("[viewer.js] Failed to load ctrl_camera_pr_env.js", e);
85
+ reject(e);
86
+ };
87
+ document.head.appendChild(s);
88
+ });
 
 
 
 
 
89
 
90
+ await window.__PLY_ORBIT_LOADING__;
91
+ }
 
 
 
 
 
 
 
 
92
 
93
+ /* -------------------------------------------
94
+ State (par module = par instance importée)
95
+ -------------------------------------------- */
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
+ let pc;
98
+ export let app = null;
99
+ let cameraEntity = null;
100
+ let modelEntity = null;
101
+ let envEntity = null; // <<< GLB instancié (focusEntity collisions)
102
+ let viewerInitialized = false;
103
+ let resizeObserver = null;
104
+ let resizeTimeout = null;
 
 
 
 
105
 
106
+ // paramètres courants de l'instance
107
+ let chosenCameraX, chosenCameraY, chosenCameraZ;
108
+ let minZoom, maxZoom, minAngle, maxAngle, minAzimuth, maxAzimuth, minY;
109
+ let modelX, modelY, modelZ, modelScale, modelRotationX, modelRotationY, modelRotationZ;
110
+ let presentoirScaleX, presentoirScaleY, presentoirScaleZ;
111
+ let sogUrl, glbUrl, presentoirUrl;
112
+ let color_bg_hex, color_bg, espace_expo_bool;
113
 
114
+ // perf dynamique
115
+ let maxDevicePixelRatio = 1.75; // plafond par défaut (configurable)
116
+ let interactDpr = 1.0; // DPR pendant interaction
117
+ let idleRestoreDelay = 350; // ms avant de restaurer le DPR
118
+ let idleTimer = null;
119
 
120
  /* -------------------------------------------
121
+ Initialisation
122
  -------------------------------------------- */
 
 
 
 
 
 
123
 
124
+ export async function initializeViewer(config, instanceId) {
125
+ if (viewerInitialized) return;
 
126
 
127
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
128
+ const isMobile = isIOS || /Android/i.test(navigator.userAgent);
129
+
130
+ // --- Configuration ---
131
+ // Nouveau format .sog (et compat si seul sogs_json_url est présent)
132
+ sogUrl = config.sog_url || config.sogs_json_url;
133
+
134
+ glbUrl =
135
+ config.glb_url !== undefined
136
+ ? config.glb_url
137
+ : "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/ressources/espace_expo/sol_blanc_2.glb";
138
+
139
+ presentoirUrl =
140
+ config.presentoir_url !== undefined
141
+ ? config.presentoir_url
142
+ : "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/ressources/espace_expo/sol_blanc_2.glb";
143
+
144
+ minZoom = parseFloat(config.minZoom || "1");
145
+ maxZoom = parseFloat(config.maxZoom || "20");
146
+ minAngle = parseFloat(config.minAngle || "-2000");
147
+ maxAngle = parseFloat(config.maxAngle || "2000");
148
+ minAzimuth = config.minAzimuth !== undefined ? parseFloat(config.minAzimuth) : -360;
149
+ maxAzimuth = config.maxAzimuth !== undefined ? parseFloat(config.maxAzimuth) : 360;
150
+ minY = config.minY !== undefined ? parseFloat(config.minY) : 0;
151
+
152
+ modelX = config.modelX !== undefined ? parseFloat(config.modelX) : 0;
153
+ modelY = config.modelY !== undefined ? parseFloat(config.modelY) : 0;
154
+ modelZ = config.modelZ !== undefined ? parseFloat(config.modelZ) : 0;
155
+ modelScale = config.modelScale !== undefined ? parseFloat(config.modelScale) : 1;
156
+ modelRotationX = config.modelRotationX !== undefined ? parseFloat(config.modelRotationX) : 0;
157
+ modelRotationY = config.modelRotationY !== undefined ? parseFloat(config.modelRotationY) : 0;
158
+ modelRotationZ = config.modelRotationZ !== undefined ? parseFloat(config.modelRotationZ) : 0;
159
+
160
+ presentoirScaleX = config.presentoirScaleX !== undefined ? parseFloat(config.presentoirScaleX) : 0;
161
+ presentoirScaleY = config.presentoirScaleY !== undefined ? parseFloat(config.presentoirScaleY) : 0;
162
+ presentoirScaleZ = config.presentoirScaleZ !== undefined ? parseFloat(config.presentoirScaleZ) : 0;
163
+
164
+ const cameraX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0;
165
+ const cameraY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 2;
166
+ const cameraZ = config.cameraZ !== undefined ? parseFloat(config.cameraZ) : 5;
167
+
168
+ const cameraXPhone = config.cameraXPhone !== undefined ? parseFloat(config.cameraXPhone) : cameraX;
169
+ const cameraYPhone = config.cameraYPhone !== undefined ? parseFloat(config.cameraYPhone) : cameraY;
170
+ const cameraZPhone = config.cameraZPhone !== undefined ? parseFloat(config.cameraZPhone) : cameraZ * 1.5;
171
+
172
+ color_bg_hex = config.canvas_background !== undefined ? config.canvas_background : "#FFFFFF";
173
+ espace_expo_bool = config.espace_expo_bool !== undefined ? config.espace_expo_bool : false;
174
  color_bg = hexToRgbaArray(color_bg_hex);
175
 
176
+ chosenCameraX = isMobile ? cameraXPhone : cameraX;
177
+ chosenCameraY = isMobile ? cameraYPhone : cameraY;
178
+ chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
 
179
 
180
+ // Options perf configurables
 
 
 
 
 
 
 
 
181
  if (config.maxDevicePixelRatio !== undefined) {
182
  maxDevicePixelRatio = Math.max(0.75, parseFloat(config.maxDevicePixelRatio) || maxDevicePixelRatio);
183
  }
 
188
  idleRestoreDelay = Math.max(120, parseInt(config.idleRestoreDelayMs, 10) || idleRestoreDelay);
189
  }
190
 
191
+ // --- Prépare le canvas unique à cette instance ---
192
+ const canvasId = "canvas-" + instanceId;
193
+ const progressDialog = document.getElementById("progress-dialog-" + instanceId);
194
+ const viewerContainer = document.getElementById("viewer-container-" + instanceId);
 
 
 
 
 
 
 
 
 
195
 
196
  const old = document.getElementById(canvasId);
197
  if (old) old.remove();
198
 
199
+ const canvas = document.createElement("canvas");
200
  canvas.id = canvasId;
201
+ canvas.className = "ply-canvas";
202
+ canvas.style.width = "100%";
203
+ canvas.style.height = "100%";
204
+ canvas.setAttribute("tabindex", "0");
205
+ viewerContainer.insertBefore(canvas, progressDialog);
206
+
207
+ // interactions de base
208
+ canvas.style.touchAction = "none";
209
+ canvas.style.webkitTouchCallout = "none";
210
+ canvas.addEventListener("gesturestart", (e) => e.preventDefault());
211
+ canvas.addEventListener("gesturechange", (e) => e.preventDefault());
212
+ canvas.addEventListener("gestureend", (e) => e.preventDefault());
213
+ canvas.addEventListener("dblclick", (e) => e.preventDefault());
214
+ canvas.addEventListener(
215
+ "touchstart",
216
+ (e) => {
217
+ if (e.touches.length > 1) e.preventDefault();
218
+ },
219
+ { passive: false }
220
+ );
221
+ canvas.addEventListener(
222
+ "wheel",
223
+ (e) => {
224
+ e.preventDefault();
225
+ },
226
+ { passive: false }
227
+ );
228
+
229
+ // Bloque le scroll page uniquement quand le pointeur est sur le canvas
230
+ const scrollKeys = new Set([
231
+ "ArrowUp",
232
+ "ArrowDown",
233
+ "ArrowLeft",
234
+ "ArrowRight",
235
+ "PageUp",
236
+ "PageDown",
237
+ "Home",
238
+ "End",
239
+ " ",
240
+ "Space",
241
+ "Spacebar"
242
+ ]);
243
  let isPointerOverCanvas = false;
244
  const focusCanvas = () => canvas.focus({ preventScroll: true });
245
+
246
+ const onPointerEnter = () => {
247
+ isPointerOverCanvas = true;
248
+ focusCanvas();
249
+ };
250
+ const onPointerLeave = () => {
251
+ isPointerOverCanvas = false;
252
+ if (document.activeElement === canvas) canvas.blur();
253
+ };
254
+ const onCanvasBlur = () => {
255
+ isPointerOverCanvas = false;
256
+ };
257
+
258
+ canvas.addEventListener("pointerenter", onPointerEnter);
259
+ canvas.addEventListener("pointerleave", onPointerLeave);
260
+ canvas.addEventListener("mouseenter", onPointerEnter);
261
+ canvas.addEventListener("mouseleave", onPointerLeave);
262
+ canvas.addEventListener("mousedown", focusCanvas);
263
+ canvas.addEventListener(
264
+ "touchstart",
265
+ () => {
266
+ focusCanvas();
267
+ },
268
+ { passive: false }
269
+ );
270
+ canvas.addEventListener("blur", onCanvasBlur);
271
+
272
+ const onKeyDownCapture = (e) => {
273
+ if (!isPointerOverCanvas) return;
274
+ if (scrollKeys.has(e.key) || scrollKeys.has(e.code)) {
275
+ e.preventDefault();
276
  }
277
+ };
278
+ window.addEventListener("keydown", onKeyDownCapture, true);
279
+
280
+ progressDialog.style.display = "block";
281
+
282
+ // --- Charge PlayCanvas lib ESM (une par module/instance) ---
283
+ if (!pc) {
284
+ pc = await import("https://esm.run/playcanvas");
285
+ window.pc = pc; // utiles pour tooltips.js
286
  }
287
 
288
+ // --- Crée l'Application ---
289
+ const device = await pc.createGraphicsDevice(canvas, {
290
+ deviceTypes: ["webgl2"],
291
+ glslangUrl: "https://playcanvas.vercel.app/static/lib/glslang/glslang.js",
292
+ twgslUrl: "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js",
293
+ antialias: false
294
+ });
295
+
296
+ // Cap DPR pour limiter le coût CPU/GPU
297
  device.maxPixelRatio = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio);
298
 
299
  const opts = new pc.AppOptions();
300
  opts.graphicsDevice = device;
301
+ opts.mouse = new pc.Mouse(canvas);
302
+ opts.touch = new pc.TouchDevice(canvas);
303
+ opts.keyboard = new pc.Keyboard(canvas);
304
  opts.componentSystems = [
305
  pc.RenderComponentSystem,
306
  pc.CameraComponentSystem,
 
310
  pc.CollisionComponentSystem,
311
  pc.RigidbodyComponentSystem
312
  ];
313
+ // GSplatHandler gère nativement les .sog
314
  opts.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.ScriptHandler, pc.GSplatHandler];
315
 
316
  app = new pc.Application(canvas, opts);
317
  app.setCanvasFillMode(pc.FILLMODE_NONE);
318
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
 
319
 
320
+ // --- Debounce resize (moins de rafales) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  resizeObserver = new ResizeObserver((entries) => {
322
  if (!entries || !entries.length) return;
323
  if (resizeTimeout) clearTimeout(resizeTimeout);
 
327
  });
328
  resizeObserver.observe(viewerContainer);
329
 
330
+ window.addEventListener("resize", () => {
331
  if (resizeTimeout) clearTimeout(resizeTimeout);
332
  resizeTimeout = setTimeout(() => {
333
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
334
  }, 60);
335
  });
336
 
337
+ // Nettoyage complet
338
+ app.on("destroy", () => {
339
+ try {
340
+ resizeObserver.disconnect();
341
+ } catch {}
342
  if (opts.keyboard && opts.keyboard.detach) opts.keyboard.detach();
343
+ window.removeEventListener("keydown", onKeyDownCapture, true);
344
+
345
+ canvas.removeEventListener("pointerenter", onPointerEnter);
346
+ canvas.removeEventListener("pointerleave", onPointerLeave);
347
+ canvas.removeEventListener("mouseenter", onPointerEnter);
348
+ canvas.removeEventListener("mouseleave", onPointerLeave);
349
+ canvas.removeEventListener("mousedown", focusCanvas);
350
+ canvas.removeEventListener("touchstart", focusCanvas);
351
+ canvas.removeEventListener("blur", onCanvasBlur);
352
  });
353
 
354
+ // --- Enregistre et charge les assets en 1 phase pour SOG + GLB (focusEntity prêt avant la caméra) ---
355
+ const sogAsset = new pc.Asset("gsplat", "gsplat", { url: sogUrl });
356
+ const glbAsset = new pc.Asset("glb", "container", { url: glbUrl });
357
+ app.assets.add(sogAsset);
358
+ app.assets.add(glbAsset);
359
 
360
+ // Assure la free-cam + collisions
361
+ await ensureOrbitScriptsLoaded();
362
 
363
+ // ---------- CHARGEMENT SOG + GLB AVANT CREATION CAMERA ----------
364
+ await new Promise((resolve, reject) => {
365
+ const loader = new pc.AssetListLoader([sogAsset, glbAsset], app.assets);
366
+ loader.load(() => resolve());
367
+ loader.on('error', reject);
 
 
 
 
 
368
  });
369
 
370
+ app.start(); // démarre l'update loop dès que possible
371
+ progressDialog.style.display = "none";
372
+
373
+ // --- Modèle principal (GSplat via .sog) ---
374
+ modelEntity = new pc.Entity("model");
375
+ modelEntity.addComponent("gsplat", { asset: sogAsset });
376
+ modelEntity.setLocalPosition(modelX, modelY, modelZ);
377
+ modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ);
378
+ modelEntity.setLocalScale(modelScale, modelScale, modelScale);
379
+ app.root.addChild(modelEntity);
380
+
381
+ // --- Instancier le GLB d’environnement (collision) ---
382
+ envEntity = glbAsset.resource ? glbAsset.resource.instantiateRenderEntity() : null;
383
+ if (envEntity) {
384
+ envEntity.name = "ENV_GLTF";
385
+ app.root.addChild(envEntity);
386
+ // NOTE : évite de changer l'échelle ici, sauf si nécessaire. Si tu DOIS :
387
+ // envEntity.setLocalScale(1,1,1); // garde l'échelle d'import cohérente
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  } else {
389
+ console.warn("[viewer.js] GLB resource missing: collisions will fallback on GSplat (aucun mesh).");
390
  }
391
 
392
+ // --- Caméra + scripts d’input (free-cam nommée 'orbitCamera' pour compat) ---
393
+ cameraEntity = new pc.Entity("camera");
394
+ cameraEntity.addComponent("camera", {
395
+ clearColor: new pc.Color(color_bg),
396
+ nearClip: 0.001,
397
+ farClip: 100
398
+ });
399
+ cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
400
+ cameraEntity.lookAt(modelEntity.getPosition());
401
+ cameraEntity.addComponent("script");
402
+
403
+ // >>> focusEntity = GLB en priorité (sinon fallback gsplat) <<<
404
+ cameraEntity.script.create("orbitCamera", {
405
+ attributes: {
406
+ focusEntity: envEntity || modelEntity,
407
+ // Attributs hérités pour compat, mais seront interprétés par la free-cam
408
+ inertiaFactor: 0.2,
409
+ distanceMax: maxZoom,
410
+ distanceMin: minZoom,
411
+ pitchAngleMax: maxAngle,
412
+ pitchAngleMin: minAngle,
413
+ yawAngleMax: maxAzimuth,
414
+ yawAngleMin: minAzimuth,
415
+ minY: minY,
416
+ frameOnStart: false
417
+ }
418
+ });
419
+ cameraEntity.script.create("orbitCameraInputMouse");
420
+ cameraEntity.script.create("orbitCameraInputTouch");
421
+ cameraEntity.script.create("orbitCameraInputKeyboard", {
422
+ attributes: {
423
+ forwardSpeed: 1.2,
424
+ strafeSpeed: 1.2
425
+ }
426
+ });
427
+ app.root.addChild(cameraEntity);
428
+
429
+ // Taille initiale
430
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
431
 
432
+ // IMPORTANT : si la free-cam est active, ne pas "forcer" un reset d'orbite.
433
+ app.once("update", () => resetViewerCamera());
434
+
435
+ // ---------- Perf dynamique : DPR temporairement réduit pendant interaction ----------
436
  const setDpr = (val) => {
437
  const clamped = Math.max(0.5, Math.min(val, maxDevicePixelRatio));
438
  if (app.graphicsDevice.maxPixelRatio !== clamped) {
 
440
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
441
  }
442
  };
443
+
444
  const bumpInteraction = () => {
445
  setDpr(interactDpr);
446
  if (idleTimer) clearTimeout(idleTimer);
 
448
  setDpr(Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio));
449
  }, idleRestoreDelay);
450
  };
 
 
451
 
452
+ const interactionEvents = ["mousedown", "mousemove", "mouseup", "wheel", "touchstart", "touchmove", "keydown"];
453
+ interactionEvents.forEach((ev) => {
454
+ canvas.addEventListener(ev, bumpInteraction, { passive: true });
455
+ });
456
 
457
+ // ---------- CHARGEMENT DIFFÉRÉ : présentoir et tooltips ----------
458
+ setTimeout(async () => {
459
+ try {
460
+ const presentoirAsset = new pc.Asset("presentoir", "container", { url: presentoirUrl });
461
+ app.assets.add(presentoirAsset);
462
+ await new Promise((resolve) => {
463
+ const loader2 = new pc.AssetListLoader([presentoirAsset], app.assets);
464
+ loader2.load(() => resolve());
465
+ });
466
 
467
+ const presentoirEntity =
468
+ presentoirAsset.resource ? presentoirAsset.resource.instantiateRenderEntity() : null;
469
+ if (presentoirEntity) {
470
+ presentoirEntity.setLocalScale(presentoirScaleX, presentoirScaleY, presentoirScaleZ);
471
+ app.root.addChild(presentoirEntity);
472
+ }
473
 
474
+ // Si pas d'espace expo : recolore GLB & présentoir pour fond uni
475
+ if (!espace_expo_bool) {
476
+ const matSol = new pc.StandardMaterial();
477
+ matSol.blendType = pc.BLEND_NONE;
478
+ matSol.emissive = new pc.Color(color_bg);
479
+ matSol.emissiveIntensity = 1;
480
+ matSol.useLighting = false;
481
+ matSol.update();
482
+
483
+ if (presentoirEntity) {
484
+ traverse(presentoirEntity, (node) => {
485
+ if (node.render && node.render.meshInstances) {
486
+ for (const mi of node.render.meshInstances) mi.material = matSol;
487
+ }
488
+ });
489
+ }
490
 
491
+ if (envEntity) {
492
+ traverse(envEntity, (node) => {
493
+ if (node.render && node.render.meshInstances) {
494
+ for (const mi of node.render.meshInstances) mi.material = matSol;
495
+ }
496
+ });
497
+ }
498
+ }
499
+
500
+ // Tooltips (optionnels)
501
+ try {
502
+ if (config.tooltips_url) {
503
+ import("./tooltips.js")
504
+ .then((tooltipsModule) => {
505
+ tooltipsModule.initializeTooltips({
506
+ app,
507
+ cameraEntity,
508
+ modelEntity,
509
+ tooltipsUrl: config.tooltips_url,
510
+ defaultVisible: !!config.showTooltipsDefault,
511
+ moveDuration: config.tooltipMoveDuration || 0.6
512
+ });
513
+ })
514
+ .catch(() => { /* optional */ });
515
+ }
516
+ } catch (e) { /* optional */ }
517
+ } catch (e) {
518
+ console.warn("[viewer.js] Deferred assets load failed:", e);
519
+ }
520
+ }, 0);
521
 
522
+ viewerInitialized = true;
 
523
  }
524
 
525
  /* -------------------------------------------
526
+ Reset caméra (API)
527
  -------------------------------------------- */
 
 
 
 
 
528
 
529
+ export function resetViewerCamera() {
530
+ try {
531
+ if (!cameraEntity || !modelEntity || !app) return;
532
+ const camScript = cameraEntity.script && cameraEntity.script.orbitCamera;
533
+ if (!camScript) return;
534
+
535
+ const modelPos = modelEntity.getPosition();
536
+
537
+ // Si c'est une FREE-CAM (notre script), ne pas toucher à des champs d'orbite.
538
+ // On se contente éventuellement de réaligner le regard.
539
+ const looksLikeOrbit =
540
+ ("pivotPoint" in camScript) ||
541
+ ("_distance" in camScript) ||
542
+ ("_updatePosition" in camScript);
543
+
544
+ if (!looksLikeOrbit) {
545
+ // Free camera : juste orienter vers le modèle si souhaité
546
+ cameraEntity.lookAt(modelPos);
547
+ return;
548
  }
549
+
550
+ // --- Cas d'une vraie orbit-camera (compat héritée) ---
551
+ const orbitCam = camScript;
552
+
553
+ const tempEnt = new pc.Entity();
554
+ tempEnt.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
555
+ tempEnt.lookAt(modelPos);
556
+
557
+ const dist = new pc.Vec3()
558
+ .sub2(new pc.Vec3(chosenCameraX, chosenCameraY, chosenCameraZ), modelPos)
559
+ .length();
560
+
561
+ cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
562
+ cameraEntity.lookAt(modelPos);
563
+
564
+ orbitCam.pivotPoint = modelPos.clone();
565
+ orbitCam._targetDistance = dist;
566
+ orbitCam._distance = dist;
567
+
568
+ const rot = tempEnt.getRotation();
569
+ const fwd = new pc.Vec3();
570
+ rot.transformVector(pc.Vec3.FORWARD, fwd);
571
+
572
+ const yaw = Math.atan2(-fwd.x, -fwd.z) * pc.math.RAD_TO_DEG;
573
+ const yawQuat = new pc.Quat().setFromEulerAngles(0, -yaw, 0);
574
+ const rotNoYaw = new pc.Quat().mul2(yawQuat, rot);
575
+ const fNoYaw = new pc.Vec3();
576
+ rotNoYaw.transformVector(pc.Vec3.FORWARD, fNoYaw);
577
+ const pitch = Math.atan2(fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG;
578
+
579
+ orbitCam._targetYaw = yaw;
580
+ orbitCam._yaw = yaw;
581
+ orbitCam._targetPitch = pitch;
582
+ orbitCam._pitch = pitch;
583
+ if (orbitCam._updatePosition) orbitCam._updatePosition();
584
+
585
+ tempEnt.destroy();
586
+ } catch (e) {
587
+ console.error("[viewer.js] resetViewerCamera error:", e);
588
  }
589
  }