MikaFil commited on
Commit
3508121
·
verified ·
1 Parent(s): bbd5eee

Update deplacement_dans_env/viewer_pr_env.js

Browse files
Files changed (1) hide show
  1. deplacement_dans_env/viewer_pr_env.js +251 -404
deplacement_dans_env/viewer_pr_env.js CHANGED
@@ -1,39 +1,49 @@
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("#", "");
@@ -46,138 +56,81 @@ function hexToRgbaArray(hex) {
46
  ((num >> 8) & 0xff) / 255,
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,7 +141,13 @@ export async function initializeViewer(config, instanceId) {
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);
@@ -204,103 +163,66 @@ export async function initializeViewer(config, instanceId) {
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,14 +232,16 @@ export async function initializeViewer(config, instanceId) {
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);
@@ -334,11 +258,8 @@ export async function initializeViewer(config, instanceId) {
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
 
@@ -351,91 +272,130 @@ export async function initializeViewer(config, instanceId) {
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
-
387
- console.log('[VIEWER] env ready:', !!envEntity, 'meshes:',
388
- envEntity?.render?.meshInstances?.length || '(via children)');
389
- // NOTE : évite de changer l'échelle ici, sauf si nécessaire. Si tu DOIS :
390
- // envEntity.setLocalScale(1,1,1); // garde l'échelle d'import cohérente
391
  } else {
392
- console.warn("[viewer.js] GLB resource missing: collisions will fallback on GSplat (aucun mesh).");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  }
394
 
395
- // --- Caméra + scripts d’input (free-cam nommée 'orbitCamera' pour compat) ---
396
  cameraEntity = new pc.Entity("camera");
397
  cameraEntity.addComponent("camera", {
398
  clearColor: new pc.Color(color_bg),
399
- nearClip: 0.001,
400
- farClip: 100
401
- });
402
- cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
403
- cameraEntity.lookAt(modelEntity.getPosition());
404
- cameraEntity.addComponent("script");
405
-
406
- // >>> focusEntity = GLB en priorité (sinon fallback gsplat) <<<
407
- cameraEntity.script.create("orbitCamera", {
408
- attributes: {
409
- focusEntity: envEntity || modelEntity,
410
- // Attributs hérités pour compat, mais seront interprétés par la free-cam
411
- inertiaFactor: 0.2,
412
- distanceMax: maxZoom,
413
- distanceMin: minZoom,
414
- pitchAngleMax: maxAngle,
415
- pitchAngleMin: minAngle,
416
- yawAngleMax: maxAzimuth,
417
- yawAngleMin: minAzimuth,
418
- minY: minY,
419
- frameOnStart: false
420
- }
421
- });
422
- cameraEntity.script.create("orbitCameraInputMouse");
423
- cameraEntity.script.create("orbitCameraInputTouch");
424
- cameraEntity.script.create("orbitCameraInputKeyboard", {
425
- attributes: {
426
- forwardSpeed: 1.2,
427
- strafeSpeed: 1.2
428
- }
429
  });
430
- app.root.addChild(cameraEntity);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
  // Taille initiale
433
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
434
 
435
- // IMPORTANT : si la free-cam est active, ne pas "forcer" un reset d'orbite.
436
- app.once("update", () => resetViewerCamera());
437
-
438
- // ---------- Perf dynamique : DPR temporairement réduit pendant interaction ----------
439
  const setDpr = (val) => {
440
  const clamped = Math.max(0.5, Math.min(val, maxDevicePixelRatio));
441
  if (app.graphicsDevice.maxPixelRatio !== clamped) {
@@ -443,7 +403,6 @@ export async function initializeViewer(config, instanceId) {
443
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
444
  }
445
  };
446
-
447
  const bumpInteraction = () => {
448
  setDpr(interactDpr);
449
  if (idleTimer) clearTimeout(idleTimer);
@@ -453,140 +412,28 @@ export async function initializeViewer(config, instanceId) {
453
  };
454
 
455
  const interactionEvents = ["mousedown", "mousemove", "mouseup", "wheel", "touchstart", "touchmove", "keydown"];
456
- interactionEvents.forEach((ev) => {
457
- canvas.addEventListener(ev, bumpInteraction, { passive: true });
458
- });
459
-
460
- // ---------- CHARGEMENT DIFFÉRÉ : présentoir et tooltips ----------
461
- setTimeout(async () => {
462
- try {
463
- const presentoirAsset = new pc.Asset("presentoir", "container", { url: presentoirUrl });
464
- app.assets.add(presentoirAsset);
465
- await new Promise((resolve) => {
466
- const loader2 = new pc.AssetListLoader([presentoirAsset], app.assets);
467
- loader2.load(() => resolve());
468
- });
469
-
470
- const presentoirEntity =
471
- presentoirAsset.resource ? presentoirAsset.resource.instantiateRenderEntity() : null;
472
- if (presentoirEntity) {
473
- presentoirEntity.setLocalScale(presentoirScaleX, presentoirScaleY, presentoirScaleZ);
474
- app.root.addChild(presentoirEntity);
475
- }
476
-
477
- // Si pas d'espace expo : recolore GLB & présentoir pour fond uni
478
- if (!espace_expo_bool) {
479
- const matSol = new pc.StandardMaterial();
480
- matSol.blendType = pc.BLEND_NONE;
481
- matSol.emissive = new pc.Color(color_bg);
482
- matSol.emissiveIntensity = 1;
483
- matSol.useLighting = false;
484
- matSol.update();
485
-
486
- if (presentoirEntity) {
487
- traverse(presentoirEntity, (node) => {
488
- if (node.render && node.render.meshInstances) {
489
- for (const mi of node.render.meshInstances) mi.material = matSol;
490
- }
491
- });
492
- }
493
-
494
- if (envEntity) {
495
- traverse(envEntity, (node) => {
496
- if (node.render && node.render.meshInstances) {
497
- for (const mi of node.render.meshInstances) mi.material = matSol;
498
- }
499
- });
500
- }
501
- }
502
-
503
- // Tooltips (optionnels)
504
- try {
505
- if (config.tooltips_url) {
506
- import("./tooltips.js")
507
- .then((tooltipsModule) => {
508
- tooltipsModule.initializeTooltips({
509
- app,
510
- cameraEntity,
511
- modelEntity,
512
- tooltipsUrl: config.tooltips_url,
513
- defaultVisible: !!config.showTooltipsDefault,
514
- moveDuration: config.tooltipMoveDuration || 0.6
515
- });
516
- })
517
- .catch(() => { /* optional */ });
518
- }
519
- } catch (e) { /* optional */ }
520
- } catch (e) {
521
- console.warn("[viewer.js] Deferred assets load failed:", e);
522
- }
523
- }, 0);
524
 
525
  viewerInitialized = true;
 
 
 
526
  }
527
 
528
  /* -------------------------------------------
529
- Reset caméra (API)
530
  -------------------------------------------- */
531
-
532
- export function resetViewerCamera() {
533
- try {
534
- if (!cameraEntity || !modelEntity || !app) return;
535
- const camScript = cameraEntity.script && cameraEntity.script.orbitCamera;
536
- if (!camScript) return;
537
-
538
- const modelPos = modelEntity.getPosition();
539
-
540
- // Si c'est une FREE-CAM (notre script), ne pas toucher à des champs d'orbite.
541
- // On se contente éventuellement de réaligner le regard.
542
- const looksLikeOrbit =
543
- ("pivotPoint" in camScript) ||
544
- ("_distance" in camScript) ||
545
- ("_updatePosition" in camScript);
546
-
547
- if (!looksLikeOrbit) {
548
- // Free camera : juste orienter vers le modèle si souhaité
549
- cameraEntity.lookAt(modelPos);
550
- return;
551
- }
552
-
553
- // --- Cas d'une vraie orbit-camera (compat héritée) ---
554
- const orbitCam = camScript;
555
-
556
- const tempEnt = new pc.Entity();
557
- tempEnt.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
558
- tempEnt.lookAt(modelPos);
559
-
560
- const dist = new pc.Vec3()
561
- .sub2(new pc.Vec3(chosenCameraX, chosenCameraY, chosenCameraZ), modelPos)
562
- .length();
563
-
564
- cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
565
- cameraEntity.lookAt(modelPos);
566
-
567
- orbitCam.pivotPoint = modelPos.clone();
568
- orbitCam._targetDistance = dist;
569
- orbitCam._distance = dist;
570
-
571
- const rot = tempEnt.getRotation();
572
- const fwd = new pc.Vec3();
573
- rot.transformVector(pc.Vec3.FORWARD, fwd);
574
-
575
- const yaw = Math.atan2(-fwd.x, -fwd.z) * pc.math.RAD_TO_DEG;
576
- const yawQuat = new pc.Quat().setFromEulerAngles(0, -yaw, 0);
577
- const rotNoYaw = new pc.Quat().mul2(yawQuat, rot);
578
- const fNoYaw = new pc.Vec3();
579
- rotNoYaw.transformVector(pc.Vec3.FORWARD, fNoYaw);
580
- const pitch = Math.atan2(fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG;
581
-
582
- orbitCam._targetYaw = yaw;
583
- orbitCam._yaw = yaw;
584
- orbitCam._targetPitch = pitch;
585
- orbitCam._pitch = pitch;
586
- if (orbitCam._updatePosition) orbitCam._updatePosition();
587
-
588
- tempEnt.destroy();
589
- } catch (e) {
590
- console.error("[viewer.js] resetViewerCamera error:", e);
591
  }
592
  }
 
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
+ let pc;
15
+ export let app = null;
16
+ export let cameraEntity = null;
17
+ export let playerEntity = null; // capsule dynamique (rigidbody)
18
+ export let envEntity = null; // GLB (environnement)
19
+ export let modelEntity = null; // GSplat principal (.sog)
20
 
21
+ let viewerInitialized = false;
22
+ let resizeObserver = null;
23
+ let resizeTimeout = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ // Config / paramètres courants
26
+ let sogUrl, glbUrl, presentoirUrl;
27
+ let color_bg_hex, color_bg;
28
+ let espace_expo_bool;
29
+
30
+ // Camera spawn
31
+ let chosenCameraX, chosenCameraY, chosenCameraZ;
 
 
32
 
33
+ // DPR / perf
34
+ let maxDevicePixelRatio = 1.75;
35
+ let interactDpr = 1.0;
36
+ let idleRestoreDelay = 350;
37
+ let idleTimer = null;
38
+
39
+ // Physique
40
+ let ammoBaseUrl = "https://playcanvas.github.io/examples/lib/ammo/"; // surchargable par config
41
+ let physicsEnabled = true; // si Ammo indisponible -> false
42
+ let gravityVec = null; // sera défini selon config (free-fly vs grounded)
43
+
44
+ /* -------------------------------------------
45
+ Utils
46
+ -------------------------------------------- */
47
  function hexToRgbaArray(hex) {
48
  try {
49
  hex = String(hex || "").replace("#", "");
 
56
  ((num >> 8) & 0xff) / 255,
57
  (num & 0xff) / 255
58
  ];
59
+ } catch {
 
60
  return [1, 1, 1, 1];
61
  }
62
  }
63
 
64
+ function traverse(entity, cb) {
65
+ cb(entity);
66
+ if (entity.children && entity.children.length) {
67
+ entity.children.forEach((c) => traverse(c, cb));
 
68
  }
69
  }
70
 
71
+ function isMobileUA() {
72
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
73
+ const isAndroid = /Android/i.test(navigator.userAgent);
74
+ return isIOS || isAndroid;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  }
76
 
77
+ async function loadAmmoOrFallback(baseUrl, timeoutMs = 4000) {
78
+ // Configure les URLs Ammo (WASM + fallback JS)
79
+ pc.WasmModule.setConfig("Ammo", {
80
+ glueUrl: `${baseUrl}ammo.wasm.js`,
81
+ wasmUrl: `${baseUrl}ammo.wasm.wasm`,
82
+ fallbackUrl: `${baseUrl}ammo.js`
83
+ });
 
 
 
 
 
84
 
85
+ let resolved = false;
86
+ await Promise.race([
87
+ new Promise((resolve) => {
88
+ pc.WasmModule.getInstance("Ammo", () => {
89
+ resolved = true;
90
+ resolve();
91
+ });
92
+ }),
93
+ new Promise((resolve) => setTimeout(resolve, timeoutMs))
94
+ ]);
95
 
96
+ if (!resolved) {
97
+ console.warn("[viewer_pr_env] Ammo not ready within timeout physics disabled (fallback rendering only).");
98
+ return false;
99
+ }
100
+ return true;
101
+ }
102
 
103
  /* -------------------------------------------
104
+ Initialisation principale
105
  -------------------------------------------- */
 
106
  export async function initializeViewer(config, instanceId) {
107
  if (viewerInitialized) return;
108
 
109
+ // ---- Lecture config ----
110
+ const mobile = isMobileUA();
111
+
112
+ sogUrl = config.sog_url || config.sogs_json_url || null;
113
+ glbUrl = (config.glb_url !== undefined) ? config.glb_url : null;
114
+ presentoirUrl = (config.presentoir_url !== undefined) ? config.presentoir_url : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  color_bg_hex = config.canvas_background !== undefined ? config.canvas_background : "#FFFFFF";
117
+ espace_expo_bool = config.espace_expo_bool !== undefined ? !!config.espace_expo_bool : false;
118
  color_bg = hexToRgbaArray(color_bg_hex);
119
 
120
+ // Camera spawn valeurs par défaut
121
+ const camX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0;
122
+ const camY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 1.6;
123
+ const camZ = config.cameraZ !== undefined ? parseFloat(config.cameraZ) : 4.0;
124
 
125
+ const camXPhone = config.cameraXPhone !== undefined ? parseFloat(config.cameraXPhone) : camX;
126
+ const camYPhone = config.cameraYPhone !== undefined ? parseFloat(config.cameraYPhone) : camY;
127
+ const camZPhone = config.cameraZPhone !== undefined ? parseFloat(config.cameraZPhone) : camZ * 1.5;
128
+
129
+ chosenCameraX = mobile ? camXPhone : camX;
130
+ chosenCameraY = mobile ? camYPhone : camY;
131
+ chosenCameraZ = mobile ? camZPhone : camZ;
132
+
133
+ // DPR / perf
134
  if (config.maxDevicePixelRatio !== undefined) {
135
  maxDevicePixelRatio = Math.max(0.75, parseFloat(config.maxDevicePixelRatio) || maxDevicePixelRatio);
136
  }
 
141
  idleRestoreDelay = Math.max(120, parseInt(config.idleRestoreDelayMs, 10) || idleRestoreDelay);
142
  }
143
 
144
+ // Physique
145
+ if (config.ammoBaseUrl) ammoBaseUrl = String(config.ammoBaseUrl).endsWith("/") ? config.ammoBaseUrl : (config.ammoBaseUrl + "/");
146
+ physicsEnabled = config.usePhysics === false ? false : true; // par défaut true
147
+ const freeFly = !!config.freeFly; // si true => pas de gravité
148
+ gravityVec = freeFly ? new pc.Vec3(0, 0, 0) : new pc.Vec3(0, -9.81, 0);
149
+
150
+ // ---- Canvas / DOM ----
151
  const canvasId = "canvas-" + instanceId;
152
  const progressDialog = document.getElementById("progress-dialog-" + instanceId);
153
  const viewerContainer = document.getElementById("viewer-container-" + instanceId);
 
163
  canvas.setAttribute("tabindex", "0");
164
  viewerContainer.insertBefore(canvas, progressDialog);
165
 
166
+ // Interaction UI de base (prévenir scroll)
167
  canvas.style.touchAction = "none";
168
  canvas.style.webkitTouchCallout = "none";
169
  canvas.addEventListener("gesturestart", (e) => e.preventDefault());
170
  canvas.addEventListener("gesturechange", (e) => e.preventDefault());
171
  canvas.addEventListener("gestureend", (e) => e.preventDefault());
172
  canvas.addEventListener("dblclick", (e) => e.preventDefault());
173
+ canvas.addEventListener("wheel", (e) => e.preventDefault(), { passive: false });
174
+
175
+ // Focus au survol pour capter le clavier
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  let isPointerOverCanvas = false;
177
  const focusCanvas = () => canvas.focus({ preventScroll: true });
178
+ const onPointerEnter = () => { isPointerOverCanvas = true; focusCanvas(); };
179
+ const onPointerLeave = () => { isPointerOverCanvas = false; if (document.activeElement === canvas) canvas.blur(); };
180
+ const onCanvasBlur = () => { isPointerOverCanvas = false; };
 
 
 
 
 
 
 
 
 
181
 
182
  canvas.addEventListener("pointerenter", onPointerEnter);
183
  canvas.addEventListener("pointerleave", onPointerLeave);
184
  canvas.addEventListener("mouseenter", onPointerEnter);
185
  canvas.addEventListener("mouseleave", onPointerLeave);
186
  canvas.addEventListener("mousedown", focusCanvas);
187
+ canvas.addEventListener("touchstart", focusCanvas, { passive: true });
 
 
 
 
 
 
188
  canvas.addEventListener("blur", onCanvasBlur);
189
 
190
+ // Empêche les touches scroll pendant survol
191
+ const scrollKeys = new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","PageUp","PageDown","Home","End"," ","Space","Spacebar"]);
192
+ const onKeyDownCapture = (e) => { if (!isPointerOverCanvas) return; if (scrollKeys.has(e.key) || scrollKeys.has(e.code)) e.preventDefault(); };
 
 
 
193
  window.addEventListener("keydown", onKeyDownCapture, true);
194
 
195
  progressDialog.style.display = "block";
196
 
197
+ // ---- Import PlayCanvas ----
198
  if (!pc) {
199
  pc = await import("https://esm.run/playcanvas");
200
+ window.pc = pc; // debug
201
+ }
202
+
203
+ // ---- Charge Ammo si activé ----
204
+ if (physicsEnabled) {
205
+ try {
206
+ const ok = await loadAmmoOrFallback(ammoBaseUrl, mobile ? 5000 : 3500);
207
+ physicsEnabled = !!ok;
208
+ } catch (e) {
209
+ console.warn("[viewer_pr_env] Ammo load exception, physics disabled:", e);
210
+ physicsEnabled = false;
211
+ }
212
  }
213
 
214
+ // ---- Crée lApplication ----
215
  const device = await pc.createGraphicsDevice(canvas, {
216
+ deviceTypes: ["webgl2", "webgl1"],
 
 
217
  antialias: false
218
  });
 
 
219
  device.maxPixelRatio = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio);
220
 
221
  const opts = new pc.AppOptions();
222
  opts.graphicsDevice = device;
223
+ opts.mouse = new pc.Mouse(canvas);
224
+ opts.touch = new pc.TouchDevice(canvas);
225
+ opts.keyboard= new pc.Keyboard(canvas);
226
  opts.componentSystems = [
227
  pc.RenderComponentSystem,
228
  pc.CameraComponentSystem,
 
232
  pc.CollisionComponentSystem,
233
  pc.RigidbodyComponentSystem
234
  ];
 
235
  opts.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.ScriptHandler, pc.GSplatHandler];
236
 
237
  app = new pc.Application(canvas, opts);
238
  app.setCanvasFillMode(pc.FILLMODE_NONE);
239
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
240
 
241
+ // Gravité
242
+ app.scene.gravity = gravityVec || new pc.Vec3(0, -9.81, 0);
243
+
244
+ // Resize observé (debounce)
245
  resizeObserver = new ResizeObserver((entries) => {
246
  if (!entries || !entries.length) return;
247
  if (resizeTimeout) clearTimeout(resizeTimeout);
 
258
  }, 60);
259
  });
260
 
 
261
  app.on("destroy", () => {
262
+ try { resizeObserver.disconnect(); } catch {}
 
 
263
  if (opts.keyboard && opts.keyboard.detach) opts.keyboard.detach();
264
  window.removeEventListener("keydown", onKeyDownCapture, true);
265
 
 
272
  canvas.removeEventListener("blur", onCanvasBlur);
273
  });
274
 
275
+ // ---- Assets (SOG + GLB) ----
276
+ const assets = [];
277
+ let sogAsset = null, glbAsset = null;
 
 
278
 
279
+ if (sogUrl) {
280
+ sogAsset = new pc.Asset("gsplat", "gsplat", { url: sogUrl });
281
+ app.assets.add(sogAsset); assets.push(sogAsset);
282
+ }
283
+ if (glbUrl) {
284
+ glbAsset = new pc.Asset("glb", "container", { url: glbUrl });
285
+ app.assets.add(glbAsset); assets.push(glbAsset);
286
+ }
287
 
288
+ // Charge les assets requis avant de créer la scène
289
  await new Promise((resolve, reject) => {
290
+ const loader = new pc.AssetListLoader(assets, app.assets);
291
  loader.load(() => resolve());
292
  loader.on('error', reject);
293
  });
294
 
295
+ // Démarrer la boucle update
296
+ app.start();
297
  progressDialog.style.display = "none";
298
 
299
+ // ---- Crée le modèle GSplat (optionnel) ----
300
+ if (sogAsset) {
301
+ modelEntity = new pc.Entity("model");
302
+ modelEntity.addComponent("gsplat", { asset: sogAsset });
303
+ app.root.addChild(modelEntity);
304
+ }
 
305
 
306
+ // ---- Instancier le GLB d’environnement ----
307
+ envEntity = glbAsset && glbAsset.resource ? glbAsset.resource.instantiateRenderEntity() : null;
308
  if (envEntity) {
309
  envEntity.name = "ENV_GLTF";
310
  app.root.addChild(envEntity);
 
 
 
 
 
311
  } else {
312
+ console.warn("[viewer_pr_env] Aucun GLB d’environnement collisions physiques inutilisables.");
313
+ }
314
+
315
+ // ---- Matériau "fond uni" si pas d'espace expo ----
316
+ if (!espace_expo_bool && envEntity) {
317
+ const matSol = new pc.StandardMaterial();
318
+ matSol.blendType = pc.BLEND_NONE;
319
+ matSol.emissive = new pc.Color(color_bg);
320
+ matSol.emissiveIntensity = 1;
321
+ matSol.useLighting = false;
322
+ matSol.update();
323
+ traverse(envEntity, (node) => {
324
+ if (node.render && node.render.meshInstances) {
325
+ for (const mi of node.render.meshInstances) mi.material = matSol;
326
+ }
327
+ });
328
+ }
329
+
330
+ // ---- Physique : colliders "mesh" statiques sur l’environnement ----
331
+ if (physicsEnabled && envEntity) {
332
+ const addStaticMeshCollider = (e) => {
333
+ if (e.render && !e.collision) {
334
+ e.addComponent('collision', { type: 'mesh' });
335
+ }
336
+ if (!e.rigidbody) {
337
+ e.addComponent('rigidbody', { type: 'static', friction: 0.6, restitution: 0.0 });
338
+ } else if (e.rigidbody && e.rigidbody.type !== 'static') {
339
+ // force static pour le décor
340
+ e.rigidbody.type = 'static';
341
+ }
342
+ };
343
+ traverse(envEntity, addStaticMeshCollider);
344
  }
345
 
346
+ // ---- Caméra + Player capsule ----
347
  cameraEntity = new pc.Entity("camera");
348
  cameraEntity.addComponent("camera", {
349
  clearColor: new pc.Color(color_bg),
350
+ nearClip: 0.03,
351
+ farClip: 500,
352
+ // viewport: peut être défini si besoin
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  });
354
+
355
+ if (physicsEnabled) {
356
+ // Player (rigidbody capsule) — porteur de la caméra
357
+ playerEntity = new pc.Entity("Player");
358
+ const capsuleRadius = config.capsuleRadius !== undefined ? parseFloat(config.capsuleRadius) : 0.30;
359
+ const capsuleHeight = config.capsuleHeight !== undefined ? parseFloat(config.capsuleHeight) : 1.60;
360
+
361
+ playerEntity.addComponent('collision', {
362
+ type: 'capsule',
363
+ radius: capsuleRadius,
364
+ height: capsuleHeight
365
+ });
366
+ playerEntity.addComponent('rigidbody', {
367
+ type: 'dynamic',
368
+ mass: 70,
369
+ friction: 0.45,
370
+ restitution: 0.0,
371
+ linearDamping: 0.15,
372
+ angularDamping: 0.999
373
+ });
374
+ // Bloquer la rotation pour éviter le roulis
375
+ playerEntity.rigidbody.angularFactor = new pc.Vec3(0, 0, 0);
376
+
377
+ // Spawn
378
+ playerEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
379
+ app.root.addChild(playerEntity);
380
+
381
+ // Caméra en enfant (yeux)
382
+ const eyes = config.eyesOffsetY !== undefined ? parseFloat(config.eyesOffsetY) : Math.max(0.1, capsuleHeight * 0.9);
383
+ cameraEntity.setPosition(0, eyes, 0);
384
+ playerEntity.addChild(cameraEntity);
385
+ } else {
386
+ // Pas de physique : on place simplement la caméra dans la scène (pas de contrôle ici)
387
+ cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
388
+ app.root.addChild(cameraEntity);
389
+ }
390
+
391
+ // Regarder vers le modèle si présent, sinon vers l’origine
392
+ const lookTarget = modelEntity ? modelEntity.getPosition() : new pc.Vec3(0, 1, 0);
393
+ cameraEntity.lookAt(lookTarget);
394
 
395
  // Taille initiale
396
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
397
 
398
+ // DPR dynamique : réduit pendant interaction
 
 
 
399
  const setDpr = (val) => {
400
  const clamped = Math.max(0.5, Math.min(val, maxDevicePixelRatio));
401
  if (app.graphicsDevice.maxPixelRatio !== clamped) {
 
403
  app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
404
  }
405
  };
 
406
  const bumpInteraction = () => {
407
  setDpr(interactDpr);
408
  if (idleTimer) clearTimeout(idleTimer);
 
412
  };
413
 
414
  const interactionEvents = ["mousedown", "mousemove", "mouseup", "wheel", "touchstart", "touchmove", "keydown"];
415
+ interactionEvents.forEach((ev) => canvas.addEventListener(ev, bumpInteraction, { passive: true }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
 
417
  viewerInitialized = true;
418
+
419
+ // Logs utiles
420
+ console.log("[VIEWER] physics:", physicsEnabled, "env:", !!envEntity, "sog:", !!modelEntity);
421
  }
422
 
423
  /* -------------------------------------------
424
+ API helper : repositionner caméra/joueur
425
  -------------------------------------------- */
426
+ export function resetViewerCamera(x, y, z) {
427
+ if (!app || !cameraEntity) return;
428
+ const nx = (x !== undefined) ? parseFloat(x) : chosenCameraX;
429
+ const ny = (y !== undefined) ? parseFloat(y) : chosenCameraY;
430
+ const nz = (z !== undefined) ? parseFloat(z) : chosenCameraZ;
431
+
432
+ if (playerEntity && playerEntity.rigidbody) {
433
+ playerEntity.rigidbody.teleport(nx, ny, nz, playerEntity.getRotation());
434
+ playerEntity.rigidbody.linearVelocity = pc.Vec3.ZERO.clone();
435
+ playerEntity.rigidbody.angularVelocity = pc.Vec3.ZERO.clone();
436
+ } else {
437
+ cameraEntity.setPosition(nx, ny, nz);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  }
439
  }