MikaFil commited on
Commit
c259e16
·
verified ·
1 Parent(s): e7b94ff

Update deplacement_dans_env/viewer_pr_env.js

Browse files
Files changed (1) hide show
  1. deplacement_dans_env/viewer_pr_env.js +258 -217
deplacement_dans_env/viewer_pr_env.js CHANGED
@@ -1,28 +1,28 @@
1
- // viewer_pr_env.js — Physics (Ammo) version
2
  // ============================================================================
3
- // - Charge PlayCanvas (ESM)
4
- // - Charge Ammo (WASM) avec fallback JS et timeout, base URL configurable
5
- // - Instancie le GLB d’environnement et lui ajoute des colliders statiques (mesh)
6
- // - Crée un "player" capsule dynamique et attache la caméra en enfant (yeux)
7
- // - Garde le chargement GSplat (.sog) et la gestion DPR dynamique
8
- // - NE CHARGE PAS le script de contrôle caméra ici (tu le demanderas ensuite)
 
9
  // ============================================================================
10
 
11
  /* -------------------------------------------
12
- State (module / instance)
13
  -------------------------------------------- */
14
-
15
- // === Error surface ===
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'); // doit s’afficher quoi qu’il arrive
23
-
24
-
25
 
 
 
 
26
  let pc;
27
  export let app = null;
28
  export let cameraEntity = null;
@@ -49,17 +49,20 @@ let idleRestoreDelay = 350;
49
  let idleTimer = null;
50
 
51
  // Physique
52
- let ammoBaseUrl = "https://playcanvas.github.io/examples/lib/ammo/"; // surchargable par config
53
- let physicsEnabled = true; // si Ammo indisponible -> false
54
- let gravityVec = null; // sera défini selon config (free-fly vs grounded)
55
 
56
  /* -------------------------------------------
57
  Utils
58
  -------------------------------------------- */
 
 
 
59
  function hexToRgbaArray(hex) {
60
  try {
61
- hex = String(hex || "").replace("#", "");
62
- if (hex.length === 6) hex += "FF";
63
  if (hex.length !== 8) return [1, 1, 1, 1];
64
  const num = parseInt(hex, 16);
65
  return [
@@ -68,7 +71,8 @@ function hexToRgbaArray(hex) {
68
  ((num >> 8) & 0xff) / 255,
69
  (num & 0xff) / 255
70
  ];
71
- } catch {
 
72
  return [1, 1, 1, 1];
73
  }
74
  }
@@ -86,65 +90,66 @@ function isMobileUA() {
86
  return isIOS || isAndroid;
87
  }
88
 
89
- async function loadAmmoOrFallback(baseUrl, timeoutMs = 4000) {
90
- // Configure URLs
91
- pc.WasmModule.setConfig("Ammo", {
92
- glueUrl: `${baseUrl}ammo.wasm.js`,
93
- wasmUrl: `${baseUrl}ammo.wasm.wasm`,
94
- fallbackUrl: `${baseUrl}ammo.js`
95
- });
96
-
97
- // Essaye l’API Promise d’abord
98
- const tryPromise = async () => {
99
- try {
100
- const p = pc.WasmModule.getInstance("Ammo", `${baseUrl}ammo.wasm.js`);
101
- if (p && typeof p.then === "function") {
102
- await Promise.race([
103
- p,
104
- new Promise((_, rej) => setTimeout(() => rej(new Error("Ammo load timeout (promise)")), timeoutMs))
105
- ]);
106
- return true;
107
- }
108
- return false;
109
- } catch (e) {
110
- console.warn("[Ammo] promise form failed:", e);
111
- return false;
112
- }
113
- };
114
 
115
- // Sinon, essaye l’API callback
116
- const tryCallback = async () => {
117
- let resolved = false;
118
- try {
 
119
  await Promise.race([
120
- new Promise((resolve, reject) => {
121
- pc.WasmModule.getInstance("Ammo", () => {
122
- resolved = true;
123
- resolve();
124
- });
125
- }),
126
- new Promise((_, rej) => setTimeout(() => rej(new Error("Ammo load timeout (callback)")), timeoutMs))
127
  ]);
128
- return resolved;
129
- } catch (e) {
130
- console.warn("[Ammo] callback form failed:", e);
131
- return false;
132
  }
133
- };
 
 
134
 
135
- const ok = (await tryPromise()) || (await tryCallback());
136
- if (!ok) {
137
- console.warn("[viewer] Ammo not available — physics disabled.");
138
- return false;
 
 
 
 
 
 
 
 
 
 
139
  }
140
- return true;
 
 
 
 
 
 
 
 
141
  }
142
 
143
  /* -------------------------------------------
144
  Initialisation principale
145
  -------------------------------------------- */
146
  export async function initializeViewer(config, instanceId) {
147
- if (viewerInitialized) return;
 
148
 
149
  // ---- Lecture config ----
150
  const mobile = isMobileUA();
@@ -153,7 +158,7 @@ export async function initializeViewer(config, instanceId) {
153
  glbUrl = (config.glb_url !== undefined) ? config.glb_url : null;
154
  presentoirUrl = (config.presentoir_url !== undefined) ? config.presentoir_url : null;
155
 
156
- color_bg_hex = config.canvas_background !== undefined ? config.canvas_background : "#FFFFFF";
157
  espace_expo_bool = config.espace_expo_bool !== undefined ? !!config.espace_expo_bool : false;
158
  color_bg = hexToRgbaArray(color_bg_hex);
159
 
@@ -181,36 +186,40 @@ export async function initializeViewer(config, instanceId) {
181
  idleRestoreDelay = Math.max(120, parseInt(config.idleRestoreDelayMs, 10) || idleRestoreDelay);
182
  }
183
 
184
- // Physique
185
- if (config.ammoBaseUrl) ammoBaseUrl = String(config.ammoBaseUrl).endsWith("/") ? config.ammoBaseUrl : (config.ammoBaseUrl + "/");
186
- physicsEnabled = config.usePhysics === false ? false : true; // par défaut true
187
  const freeFly = !!config.freeFly; // si true => pas de gravité
188
  gravityVec = freeFly ? new pc.Vec3(0, 0, 0) : new pc.Vec3(0, -9.81, 0);
189
 
190
  // ---- Canvas / DOM ----
191
- const canvasId = "canvas-" + instanceId;
192
- const progressDialog = document.getElementById("progress-dialog-" + instanceId);
193
- const viewerContainer = document.getElementById("viewer-container-" + instanceId);
 
 
 
194
 
195
  const old = document.getElementById(canvasId);
196
  if (old) old.remove();
197
 
198
- const canvas = document.createElement("canvas");
199
  canvas.id = canvasId;
200
- canvas.className = "ply-canvas";
201
- canvas.style.width = "100%";
202
- canvas.style.height = "100%";
203
- canvas.setAttribute("tabindex", "0");
204
- viewerContainer.insertBefore(canvas, progressDialog);
 
205
 
206
  // Interaction UI de base (prévenir scroll)
207
- canvas.style.touchAction = "none";
208
- canvas.style.webkitTouchCallout = "none";
209
- canvas.addEventListener("gesturestart", (e) => e.preventDefault());
210
- canvas.addEventListener("gesturechange", (e) => e.preventDefault());
211
- canvas.addEventListener("gestureend", (e) => e.preventDefault());
212
- canvas.addEventListener("dblclick", (e) => e.preventDefault());
213
- canvas.addEventListener("wheel", (e) => e.preventDefault(), { passive: false });
214
 
215
  // Focus au survol pour capter le clavier
216
  let isPointerOverCanvas = false;
@@ -219,43 +228,32 @@ export async function initializeViewer(config, instanceId) {
219
  const onPointerLeave = () => { isPointerOverCanvas = false; if (document.activeElement === canvas) canvas.blur(); };
220
  const onCanvasBlur = () => { isPointerOverCanvas = false; };
221
 
222
- canvas.addEventListener("pointerenter", onPointerEnter);
223
- canvas.addEventListener("pointerleave", onPointerLeave);
224
- canvas.addEventListener("mouseenter", onPointerEnter);
225
- canvas.addEventListener("mouseleave", onPointerLeave);
226
- canvas.addEventListener("mousedown", focusCanvas);
227
- canvas.addEventListener("touchstart", focusCanvas, { passive: true });
228
- canvas.addEventListener("blur", onCanvasBlur);
229
 
230
  // Empêche les touches scroll pendant survol
231
- const scrollKeys = new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","PageUp","PageDown","Home","End"," ","Space","Spacebar"]);
232
  const onKeyDownCapture = (e) => { if (!isPointerOverCanvas) return; if (scrollKeys.has(e.key) || scrollKeys.has(e.code)) e.preventDefault(); };
233
- window.addEventListener("keydown", onKeyDownCapture, true);
234
 
235
- progressDialog.style.display = "block";
236
 
237
  // ---- Import PlayCanvas ----
238
  if (!pc) {
239
- pc = await import("https://esm.run/playcanvas");
240
- window.pc = pc; // debug
241
- }
242
-
243
- // ---- Charge Ammo si activé ----
244
- if (physicsEnabled) {
245
- try {
246
- const ok = await loadAmmoOrFallback(ammoBaseUrl, mobile ? 5000 : 3500);
247
- physicsEnabled = !!ok;
248
- } catch (e) {
249
- console.warn("[viewer_pr_env] Ammo load exception, physics disabled:", e);
250
- physicsEnabled = false;
251
- }
252
  }
253
 
254
- // ---- Crée l’Application ----
255
- const device = await pc.createGraphicsDevice(canvas, {
256
- deviceTypes: ["webgl2", "webgl1"],
257
- antialias: false
258
- });
259
  device.maxPixelRatio = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio);
260
 
261
  const opts = new pc.AppOptions();
@@ -277,10 +275,25 @@ export async function initializeViewer(config, instanceId) {
277
  app = new pc.Application(canvas, opts);
278
  app.setCanvasFillMode(pc.FILLMODE_NONE);
279
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
280
-
281
- // Gravité
282
  app.scene.gravity = gravityVec || new pc.Vec3(0, -9.81, 0);
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  // Resize observé (debounce)
285
  resizeObserver = new ResizeObserver((entries) => {
286
  if (!entries || !entries.length) return;
@@ -291,68 +304,74 @@ export async function initializeViewer(config, instanceId) {
291
  });
292
  resizeObserver.observe(viewerContainer);
293
 
294
- window.addEventListener("resize", () => {
295
  if (resizeTimeout) clearTimeout(resizeTimeout);
296
  resizeTimeout = setTimeout(() => {
297
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
298
  }, 60);
299
  });
300
 
301
- app.on("destroy", () => {
302
  try { resizeObserver.disconnect(); } catch {}
303
  if (opts.keyboard && opts.keyboard.detach) opts.keyboard.detach();
304
- window.removeEventListener("keydown", onKeyDownCapture, true);
305
-
306
- canvas.removeEventListener("pointerenter", onPointerEnter);
307
- canvas.removeEventListener("pointerleave", onPointerLeave);
308
- canvas.removeEventListener("mouseenter", onPointerEnter);
309
- canvas.removeEventListener("mouseleave", onPointerLeave);
310
- canvas.removeEventListener("mousedown", focusCanvas);
311
- canvas.removeEventListener("touchstart", focusCanvas);
312
- canvas.removeEventListener("blur", onCanvasBlur);
313
  });
314
 
315
- // ---- Assets (SOG + GLB) ----
316
  const assets = [];
317
  let sogAsset = null, glbAsset = null;
318
 
319
  if (sogUrl) {
320
- sogAsset = new pc.Asset("gsplat", "gsplat", { url: sogUrl });
321
  app.assets.add(sogAsset); assets.push(sogAsset);
322
  }
323
  if (glbUrl) {
324
- glbAsset = new pc.Asset("glb", "container", { url: glbUrl });
325
  app.assets.add(glbAsset); assets.push(glbAsset);
326
  }
327
 
328
- // Charge les assets requis avant de créer la scène
329
- await new Promise((resolve, reject) => {
 
 
 
330
  const loader = new pc.AssetListLoader(assets, app.assets);
331
- loader.load(() => resolve());
332
- loader.on('error', reject);
 
 
 
 
 
 
333
  });
334
 
335
- // Démarrer la boucle update
336
- console.log("[viewer] before app.start()");
337
- app.start();
338
- console.log("[viewer] START OK");
339
- progressDialog.style.display = "none";
340
- console.log("[viewer] progress hidden");
341
 
342
  // ---- Crée le modèle GSplat (optionnel) ----
343
  if (sogAsset) {
344
- modelEntity = new pc.Entity("model");
345
- modelEntity.addComponent("gsplat", { asset: sogAsset });
346
  app.root.addChild(modelEntity);
 
347
  }
348
 
349
  // ---- Instancier le GLB d’environnement ----
350
  envEntity = glbAsset && glbAsset.resource ? glbAsset.resource.instantiateRenderEntity() : null;
351
  if (envEntity) {
352
- envEntity.name = "ENV_GLTF";
353
  app.root.addChild(envEntity);
 
354
  } else {
355
- console.warn("[viewer_pr_env] Aucun GLB d’environnement collisions physiques inutilisables.");
356
  }
357
 
358
  // ---- Matériau "fond uni" si pas d'espace expo ----
@@ -368,74 +387,49 @@ export async function initializeViewer(config, instanceId) {
368
  for (const mi of node.render.meshInstances) mi.material = matSol;
369
  }
370
  });
 
371
  }
372
 
373
- // ---- Physique : colliders "mesh" statiques sur l’environnement ----
374
- if (physicsEnabled && envEntity) {
375
- const addStaticMeshCollider = (e) => {
376
- if (e.render && !e.collision) {
377
- e.addComponent('collision', { type: 'mesh' });
378
- }
379
- if (!e.rigidbody) {
380
- e.addComponent('rigidbody', { type: 'static', friction: 0.6, restitution: 0.0 });
381
- } else if (e.rigidbody && e.rigidbody.type !== 'static') {
382
- // force static pour le décor
383
- e.rigidbody.type = 'static';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
  }
385
- };
386
- traverse(envEntity, addStaticMeshCollider);
387
- }
388
-
389
- // ---- Caméra + Player capsule ----
390
- cameraEntity = new pc.Entity("camera");
391
- cameraEntity.addComponent("camera", {
392
- clearColor: new pc.Color(color_bg),
393
- nearClip: 0.03,
394
- farClip: 500,
395
- // viewport: peut être défini si besoin
396
- });
397
-
398
- if (physicsEnabled) {
399
- // Player (rigidbody capsule) — porteur de la caméra
400
- playerEntity = new pc.Entity("Player");
401
- const capsuleRadius = config.capsuleRadius !== undefined ? parseFloat(config.capsuleRadius) : 0.30;
402
- const capsuleHeight = config.capsuleHeight !== undefined ? parseFloat(config.capsuleHeight) : 1.60;
403
-
404
- playerEntity.addComponent('collision', {
405
- type: 'capsule',
406
- radius: capsuleRadius,
407
- height: capsuleHeight
408
- });
409
- playerEntity.addComponent('rigidbody', {
410
- type: 'dynamic',
411
- mass: 70,
412
- friction: 0.45,
413
- restitution: 0.0,
414
- linearDamping: 0.15,
415
- angularDamping: 0.999
416
- });
417
- // Bloquer la rotation pour éviter le roulis
418
- playerEntity.rigidbody.angularFactor = new pc.Vec3(0, 0, 0);
419
-
420
- // Spawn
421
- playerEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
422
- app.root.addChild(playerEntity);
423
-
424
- // Caméra en enfant (yeux)
425
- const eyes = config.eyesOffsetY !== undefined ? parseFloat(config.eyesOffsetY) : Math.max(0.1, capsuleHeight * 0.9);
426
- cameraEntity.setPosition(0, eyes, 0);
427
- playerEntity.addChild(cameraEntity);
428
  } else {
429
- // Pas de physique : on place simplement la caméra dans la scène (pas de contrôle ici)
430
- cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
431
- app.root.addChild(cameraEntity);
432
  }
433
 
434
- // Regarder vers le modèle si présent, sinon vers l’origine
435
- const lookTarget = modelEntity ? modelEntity.getPosition() : new pc.Vec3(0, 1, 0);
436
- cameraEntity.lookAt(lookTarget);
437
-
438
- // Taille initiale
439
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
440
 
441
  // DPR dynamique : réduit pendant interaction
@@ -453,30 +447,77 @@ export async function initializeViewer(config, instanceId) {
453
  setDpr(Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio));
454
  }, idleRestoreDelay);
455
  };
456
-
457
- const interactionEvents = ["mousedown", "mousemove", "mouseup", "wheel", "touchstart", "touchmove", "keydown"];
458
- interactionEvents.forEach((ev) => canvas.addEventListener(ev, bumpInteraction, { passive: true }));
459
 
460
  viewerInitialized = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
 
462
- // Logs utiles
463
- console.log("[VIEWER] physics:", physicsEnabled, "env:", !!envEntity, "sog:", !!modelEntity);
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  }
465
 
466
  /* -------------------------------------------
467
  API helper : repositionner caméra/joueur
468
  -------------------------------------------- */
469
  export function resetViewerCamera(x, y, z) {
470
- if (!app || !cameraEntity) return;
471
  const nx = (x !== undefined) ? parseFloat(x) : chosenCameraX;
472
  const ny = (y !== undefined) ? parseFloat(y) : chosenCameraY;
473
  const nz = (z !== undefined) ? parseFloat(z) : chosenCameraZ;
474
 
475
  if (playerEntity && playerEntity.rigidbody) {
476
- playerEntity.rigidbody.teleport(nx, ny, nz, playerEntity.getRotation());
477
- playerEntity.rigidbody.linearVelocity = pc.Vec3.ZERO.clone();
478
- playerEntity.rigidbody.angularVelocity = pc.Vec3.ZERO.clone();
 
 
 
 
 
 
479
  } else {
480
  cameraEntity.setPosition(nx, ny, nz);
 
481
  }
482
  }
 
1
+ // viewer_pr_env.js — robuste (boot non-bloquant, logs, physics avec fallback)
2
  // ============================================================================
3
+ // - PlayCanvas ESM
4
+ // - Boot non-bloquant : l'app démarre et rend tout de suite (caméra au root)
5
+ // - Ammo (WASM) en parallèle, avec timeout + double API (promise/callback)
6
+ // - Colliders mesh posés après 1ère frame (postrender)
7
+ // - Player capsule créé seulement si Ammo OK, puis caméra reparentée
8
+ // - Chargement GSplat/GLB tolérant (logs + timeout) ; DPR dynamique ; logs jalons
9
+ // - NE CHARGE PAS le script de contrôle caméra (à ajouter côté scène)
10
  // ============================================================================
11
 
12
  /* -------------------------------------------
13
+ Error surface (avant tout)
14
  -------------------------------------------- */
15
+ window.addEventListener('error', (e) => {
 
 
16
  console.error('[BOOT] Uncaught error:', e.message || e.error, e);
17
  });
18
+ window.addEventListener('unhandledrejection', (e) => {
19
  console.error('[BOOT] Unhandled promise rejection:', e.reason);
20
  });
21
+ console.log('[BOOT] JS boot reached');
 
 
22
 
23
+ /* -------------------------------------------
24
+ State (module / instance)
25
+ -------------------------------------------- */
26
  let pc;
27
  export let app = null;
28
  export let cameraEntity = null;
 
49
  let idleTimer = null;
50
 
51
  // Physique
52
+ let ammoBaseUrl = 'https://playcanvas.github.io/examples/lib/ammo/'; // surchargable par config
53
+ let wantPhysics = true; // souhait ; peut être désactivé si Ammo KO
54
+ let gravityVec = null; // défini via config
55
 
56
  /* -------------------------------------------
57
  Utils
58
  -------------------------------------------- */
59
+ const stamp = () => new Date().toISOString().slice(11,19);
60
+ const step = (label, extra) => console.log(`[${stamp()}] [VIEWER] ${label}`, extra ?? '');
61
+
62
  function hexToRgbaArray(hex) {
63
  try {
64
+ hex = String(hex || '').replace('#', '');
65
+ if (hex.length === 6) hex += 'FF';
66
  if (hex.length !== 8) return [1, 1, 1, 1];
67
  const num = parseInt(hex, 16);
68
  return [
 
71
  ((num >> 8) & 0xff) / 255,
72
  (num & 0xff) / 255
73
  ];
74
+ } catch (e) {
75
+ console.warn('[viewer] hexToRgbaArray error:', e);
76
  return [1, 1, 1, 1];
77
  }
78
  }
 
90
  return isIOS || isAndroid;
91
  }
92
 
93
+ async function loadAmmoWithTimeout(baseUrl, timeoutMs = 4000) {
94
+ // Config URLs
95
+ try {
96
+ pc.WasmModule.setConfig('Ammo', {
97
+ glueUrl: `${baseUrl}ammo.wasm.js`,
98
+ wasmUrl: `${baseUrl}ammo.wasm.wasm`,
99
+ fallbackUrl: `${baseUrl}ammo.js`
100
+ });
101
+ } catch (e) {
102
+ console.warn('[Ammo] setConfig failed (ignored):', e);
103
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
+ // 1) Tente API Promise
106
+ try {
107
+ const maybePromise = pc.WasmModule.getInstance('Ammo', `${baseUrl}ammo.wasm.js`);
108
+ if (maybePromise && typeof maybePromise.then === 'function') {
109
+ step('Ammo: waiting (promise API)…');
110
  await Promise.race([
111
+ maybePromise,
112
+ new Promise((_, rej) => setTimeout(() => rej(new Error('Ammo load timeout (promise)')), timeoutMs))
 
 
 
 
 
113
  ]);
114
+ step('Ammo: ready (promise API)');
115
+ return true;
 
 
116
  }
117
+ } catch (e) {
118
+ console.warn('[Ammo] promise API failed:', e);
119
+ }
120
 
121
+ // 2) Tente API callback
122
+ let resolved = false;
123
+ try {
124
+ step('Ammo: waiting (callback API)…');
125
+ await Promise.race([
126
+ new Promise((resolve) => pc.WasmModule.getInstance('Ammo', () => { resolved = true; resolve(); })),
127
+ new Promise((_, rej) => setTimeout(() => rej(new Error('Ammo load timeout (callback)')), timeoutMs))
128
+ ]);
129
+ if (resolved) {
130
+ step('Ammo: ready (callback API)');
131
+ return true;
132
+ }
133
+ } catch (e) {
134
+ console.warn('[Ammo] callback API failed:', e);
135
  }
136
+
137
+ console.warn('[viewer] Ammo not available — physics disabled.');
138
+ return false;
139
+ }
140
+
141
+ function safeGetEl(id, label) {
142
+ const el = document.getElementById(id);
143
+ if (!el) console.warn(`[viewer] Missing DOM #${id} (${label})`);
144
+ return el;
145
  }
146
 
147
  /* -------------------------------------------
148
  Initialisation principale
149
  -------------------------------------------- */
150
  export async function initializeViewer(config, instanceId) {
151
+ if (viewerInitialized) { console.warn('[viewer] initializeViewer called twice — ignored'); return; }
152
+ step('A: initializeViewer begin', { instanceId });
153
 
154
  // ---- Lecture config ----
155
  const mobile = isMobileUA();
 
158
  glbUrl = (config.glb_url !== undefined) ? config.glb_url : null;
159
  presentoirUrl = (config.presentoir_url !== undefined) ? config.presentoir_url : null;
160
 
161
+ color_bg_hex = config.canvas_background !== undefined ? config.canvas_background : '#FFFFFF';
162
  espace_expo_bool = config.espace_expo_bool !== undefined ? !!config.espace_expo_bool : false;
163
  color_bg = hexToRgbaArray(color_bg_hex);
164
 
 
186
  idleRestoreDelay = Math.max(120, parseInt(config.idleRestoreDelayMs, 10) || idleRestoreDelay);
187
  }
188
 
189
+ // Physique (souhait)
190
+ if (config.ammoBaseUrl) ammoBaseUrl = String(config.ammoBaseUrl).endsWith('/') ? config.ammoBaseUrl : (config.ammoBaseUrl + '/');
191
+ wantPhysics = config.usePhysics === false ? false : true; // par défaut true
192
  const freeFly = !!config.freeFly; // si true => pas de gravité
193
  gravityVec = freeFly ? new pc.Vec3(0, 0, 0) : new pc.Vec3(0, -9.81, 0);
194
 
195
  // ---- Canvas / DOM ----
196
+ const canvasId = 'canvas-' + instanceId;
197
+ const progressId = 'progress-dialog-' + instanceId;
198
+ const containerId = 'viewer-container-' + instanceId;
199
+ const progressDialog = safeGetEl(progressId, 'progress');
200
+ const viewerContainer = safeGetEl(containerId, 'container');
201
+ if (!viewerContainer) throw new Error('Viewer container not found');
202
 
203
  const old = document.getElementById(canvasId);
204
  if (old) old.remove();
205
 
206
+ const canvas = document.createElement('canvas');
207
  canvas.id = canvasId;
208
+ canvas.className = 'ply-canvas';
209
+ canvas.style.width = '100%';
210
+ canvas.style.height = '100%';
211
+ canvas.setAttribute('tabindex', '0');
212
+ if (progressDialog) viewerContainer.insertBefore(canvas, progressDialog);
213
+ else viewerContainer.appendChild(canvas);
214
 
215
  // Interaction UI de base (prévenir scroll)
216
+ canvas.style.touchAction = 'none';
217
+ canvas.style.webkitTouchCallout = 'none';
218
+ canvas.addEventListener('gesturestart', (e) => e.preventDefault());
219
+ canvas.addEventListener('gesturechange', (e) => e.preventDefault());
220
+ canvas.addEventListener('gestureend', (e) => e.preventDefault());
221
+ canvas.addEventListener('dblclick', (e) => e.preventDefault());
222
+ canvas.addEventListener('wheel', (e) => e.preventDefault(), { passive: false });
223
 
224
  // Focus au survol pour capter le clavier
225
  let isPointerOverCanvas = false;
 
228
  const onPointerLeave = () => { isPointerOverCanvas = false; if (document.activeElement === canvas) canvas.blur(); };
229
  const onCanvasBlur = () => { isPointerOverCanvas = false; };
230
 
231
+ canvas.addEventListener('pointerenter', onPointerEnter);
232
+ canvas.addEventListener('pointerleave', onPointerLeave);
233
+ canvas.addEventListener('mouseenter', onPointerEnter);
234
+ canvas.addEventListener('mouseleave', onPointerLeave);
235
+ canvas.addEventListener('mousedown', focusCanvas);
236
+ canvas.addEventListener('touchstart', focusCanvas, { passive: true });
237
+ canvas.addEventListener('blur', onCanvasBlur);
238
 
239
  // Empêche les touches scroll pendant survol
240
+ const scrollKeys = new Set(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','PageUp','PageDown','Home','End',' ','Space','Spacebar']);
241
  const onKeyDownCapture = (e) => { if (!isPointerOverCanvas) return; if (scrollKeys.has(e.key) || scrollKeys.has(e.code)) e.preventDefault(); };
242
+ window.addEventListener('keydown', onKeyDownCapture, true);
243
 
244
+ if (progressDialog) progressDialog.style.display = 'block';
245
 
246
  // ---- Import PlayCanvas ----
247
  if (!pc) {
248
+ step('B: importing PlayCanvas…');
249
+ pc = await import('https://esm.run/playcanvas');
250
+ window.pc = pc; // debug convenience
251
+ step('B+: PlayCanvas imported');
 
 
 
 
 
 
 
 
 
252
  }
253
 
254
+ // ---- Crée l’Application (démarrage non-bloquant) ----
255
+ step('C: createGraphicsDevice');
256
+ const device = await pc.createGraphicsDevice(canvas, { deviceTypes: ['webgl2', 'webgl1'], antialias: false });
 
 
257
  device.maxPixelRatio = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio);
258
 
259
  const opts = new pc.AppOptions();
 
275
  app = new pc.Application(canvas, opts);
276
  app.setCanvasFillMode(pc.FILLMODE_NONE);
277
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
 
 
278
  app.scene.gravity = gravityVec || new pc.Vec3(0, -9.81, 0);
279
 
280
+ // Démarrer la boucle tout de suite (rendu garanti)
281
+ step('D: app.start()');
282
+ app.start();
283
+ step('D+: app started');
284
+
285
+ // Caméra fallback — toujours visible même si Ammo KO
286
+ step('E: create fallback camera');
287
+ cameraEntity = new pc.Entity('camera');
288
+ cameraEntity.addComponent('camera', {
289
+ clearColor: new pc.Color(color_bg),
290
+ nearClip: 0.03,
291
+ farClip: 500
292
+ });
293
+ cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
294
+ app.root.addChild(cameraEntity);
295
+ cameraEntity.lookAt(new pc.Vec3(0,1,0));
296
+
297
  // Resize observé (debounce)
298
  resizeObserver = new ResizeObserver((entries) => {
299
  if (!entries || !entries.length) return;
 
304
  });
305
  resizeObserver.observe(viewerContainer);
306
 
307
+ window.addEventListener('resize', () => {
308
  if (resizeTimeout) clearTimeout(resizeTimeout);
309
  resizeTimeout = setTimeout(() => {
310
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
311
  }, 60);
312
  });
313
 
314
+ app.on('destroy', () => {
315
  try { resizeObserver.disconnect(); } catch {}
316
  if (opts.keyboard && opts.keyboard.detach) opts.keyboard.detach();
317
+ window.removeEventListener('keydown', onKeyDownCapture, true);
318
+
319
+ canvas.removeEventListener('pointerenter', onPointerEnter);
320
+ canvas.removeEventListener('pointerleave', onPointerLeave);
321
+ canvas.removeEventListener('mouseenter', onPointerEnter);
322
+ canvas.removeEventListener('mouseleave', onPointerLeave);
323
+ canvas.removeEventListener('mousedown', focusCanvas);
324
+ canvas.removeEventListener('touchstart', focusCanvas);
325
+ canvas.removeEventListener('blur', onCanvasBlur);
326
  });
327
 
328
+ // ---- Charger assets SOG + GLB (tolérant) ----
329
  const assets = [];
330
  let sogAsset = null, glbAsset = null;
331
 
332
  if (sogUrl) {
333
+ sogAsset = new pc.Asset('gsplat', 'gsplat', { url: sogUrl });
334
  app.assets.add(sogAsset); assets.push(sogAsset);
335
  }
336
  if (glbUrl) {
337
+ glbAsset = new pc.Asset('glb', 'container', { url: glbUrl });
338
  app.assets.add(glbAsset); assets.push(glbAsset);
339
  }
340
 
341
+ step('F: requesting assets', { sog: !!sogUrl, glb: !!glbUrl });
342
+
343
+ await new Promise((resolve) => {
344
+ if (!assets.length) return resolve();
345
+
346
  const loader = new pc.AssetListLoader(assets, app.assets);
347
+ let done = false;
348
+ const finish = (label) => { if (!done) { done = true; step('F+: assets loaded (' + label + ')'); resolve(); } };
349
+
350
+ loader.on('error', (e) => { console.warn('[viewer] Asset load error:', e); finish('with errors'); });
351
+ loader.load(() => finish('ok'));
352
+
353
+ // garde-fou timeout
354
+ setTimeout(() => { if (!done) { console.warn('[viewer] Asset load timeout — continuing'); finish('timeout'); } }, 10000);
355
  });
356
 
357
+ if (progressDialog) { progressDialog.style.display = 'none'; step('G: progress hidden'); }
 
 
 
 
 
358
 
359
  // ---- Crée le modèle GSplat (optionnel) ----
360
  if (sogAsset) {
361
+ modelEntity = new pc.Entity('model');
362
+ modelEntity.addComponent('gsplat', { asset: sogAsset });
363
  app.root.addChild(modelEntity);
364
+ step('H: gsplat entity created');
365
  }
366
 
367
  // ---- Instancier le GLB d’environnement ----
368
  envEntity = glbAsset && glbAsset.resource ? glbAsset.resource.instantiateRenderEntity() : null;
369
  if (envEntity) {
370
+ envEntity.name = 'ENV_GLTF';
371
  app.root.addChild(envEntity);
372
+ step('I: env GLB instanced');
373
  } else {
374
+ console.warn('[viewer] No environment GLB — physics will be skipped.');
375
  }
376
 
377
  // ---- Matériau "fond uni" si pas d'espace expo ----
 
387
  for (const mi of node.render.meshInstances) mi.material = matSol;
388
  }
389
  });
390
+ step('I+: env recolored (flat)');
391
  }
392
 
393
+ // ---- Physique : chargement Ammo en parallèle, colliders après 1ère frame ----
394
+ let physicsReady = false;
395
+ if (wantPhysics) {
396
+ (async () => {
397
+ try {
398
+ const ok = await loadAmmoWithTimeout(ammoBaseUrl, isMobileUA() ? 5000 : 3500).catch(() => false);
399
+ if (!ok) { step('P: Ammo unavailable — rendering without physics'); return; }
400
+
401
+ // Pose des colliders mesh après 1ère frame pour garantir meshInstances
402
+ if (envEntity) {
403
+ app.once('postrender', () => {
404
+ const applyStaticMesh = (e) => {
405
+ if (e.render && !e.collision) {
406
+ try { e.addComponent('collision', { type: 'mesh' }); } catch (err) { console.warn('[phys] add collision failed:', err); }
407
+ }
408
+ if (!e.rigidbody) {
409
+ try { e.addComponent('rigidbody', { type: 'static', friction: 0.6, restitution: 0.0 }); } catch (err) { console.warn('[phys] add rigidbody failed:', err); }
410
+ } else if (e.rigidbody && e.rigidbody.type !== 'static') {
411
+ e.rigidbody.type = 'static';
412
+ }
413
+ };
414
+ const stack=[envEntity];
415
+ while (stack.length){ const n=stack.pop(); applyStaticMesh(n); n.children?.forEach(c=>stack.push(c)); }
416
+ step('P+: static mesh colliders ready');
417
+ });
418
+ }
419
+
420
+ // Crée Player capsule et reparent caméra
421
+ createPlayerCapsuleAndAttachCamera(app, config);
422
+ physicsReady = true;
423
+ step('Q: physics ON (player created)');
424
+ } catch (e) {
425
+ console.warn('[phys] init failed:', e);
426
  }
427
+ })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  } else {
429
+ step('P: physics disabled by config (usePhysics=false)');
 
 
430
  }
431
 
432
+ // Ajuster taille initiale
 
 
 
 
433
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
434
 
435
  // DPR dynamique : réduit pendant interaction
 
447
  setDpr(Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio));
448
  }, idleRestoreDelay);
449
  };
450
+ ['mousedown', 'mousemove', 'mouseup', 'wheel', 'touchstart', 'touchmove', 'keydown']
451
+ .forEach((ev) => canvas.addEventListener(ev, bumpInteraction, { passive: true }));
 
452
 
453
  viewerInitialized = true;
454
+ step('Z: initializeViewer done', { physicsRequested: wantPhysics });
455
+ }
456
+
457
+ /* -------------------------------------------
458
+ Helpers: création Player capsule + cam
459
+ -------------------------------------------- */
460
+ function createPlayerCapsuleAndAttachCamera(app, config) {
461
+ // Si déjà présent, ne pas recréer
462
+ if (playerEntity && playerEntity.rigidbody) { step('Q+: player already exists'); return; }
463
+
464
+ playerEntity = new pc.Entity('Player');
465
+ const capsuleRadius = config.capsuleRadius !== undefined ? parseFloat(config.capsuleRadius) : 0.30;
466
+ const capsuleHeight = config.capsuleHeight !== undefined ? parseFloat(config.capsuleHeight) : 1.60;
467
+
468
+ try { playerEntity.addComponent('collision', { type: 'capsule', radius: capsuleRadius, height: capsuleHeight }); }
469
+ catch (e) { console.warn('[phys] add capsule collision failed:', e); }
470
+
471
+ try {
472
+ playerEntity.addComponent('rigidbody', {
473
+ type: 'dynamic',
474
+ mass: 70,
475
+ friction: 0.45,
476
+ restitution: 0.0,
477
+ linearDamping: 0.15,
478
+ angularDamping: 0.999
479
+ });
480
+ playerEntity.rigidbody.angularFactor = new pc.Vec3(0, 0, 0); // éviter roulis
481
+ } catch (e) { console.warn('[phys] add rigidbody failed:', e); }
482
 
483
+ // Spawn
484
+ playerEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
485
+ app.root.addChild(playerEntity);
486
+
487
+ // Caméra -> enfant Player (yeux)
488
+ const eyes = config.eyesOffsetY !== undefined ? parseFloat(config.eyesOffsetY) : Math.max(0.1, capsuleHeight * 0.9);
489
+ try {
490
+ cameraEntity.reparent(playerEntity);
491
+ cameraEntity.setLocalPosition(0, eyes, 0);
492
+ cameraEntity.setLocalEulerAngles(0, 0, 0);
493
+ } catch (e) { console.warn('[phys] camera reparent failed:', e); }
494
+
495
+ // Gravité selon freeFly
496
+ const freeFly = !!config.freeFly;
497
+ app.scene.gravity = freeFly ? new pc.Vec3(0,0,0) : new pc.Vec3(0,-9.81,0);
498
  }
499
 
500
  /* -------------------------------------------
501
  API helper : repositionner caméra/joueur
502
  -------------------------------------------- */
503
  export function resetViewerCamera(x, y, z) {
504
+ if (!app || !cameraEntity) { console.warn('[viewer] resetViewerCamera: app/camera missing'); return; }
505
  const nx = (x !== undefined) ? parseFloat(x) : chosenCameraX;
506
  const ny = (y !== undefined) ? parseFloat(y) : chosenCameraY;
507
  const nz = (z !== undefined) ? parseFloat(z) : chosenCameraZ;
508
 
509
  if (playerEntity && playerEntity.rigidbody) {
510
+ try {
511
+ playerEntity.rigidbody.teleport(nx, ny, nz, playerEntity.getRotation());
512
+ playerEntity.rigidbody.linearVelocity = pc.Vec3.ZERO.clone();
513
+ playerEntity.rigidbody.angularVelocity = pc.Vec3.ZERO.clone();
514
+ step('resetViewerCamera: teleported player', { nx, ny, nz });
515
+ } catch (e) {
516
+ console.warn('[viewer] teleport failed, fallback to camera setPosition:', e);
517
+ cameraEntity.setPosition(nx, ny, nz);
518
+ }
519
  } else {
520
  cameraEntity.setPosition(nx, ny, nz);
521
+ step('resetViewerCamera: camera moved (no player)', { nx, ny, nz });
522
  }
523
  }