MikaFil commited on
Commit
748df2a
·
verified ·
1 Parent(s): 0ab9780

Update deplacement_dans_env/viewer_pr_env.js

Browse files
Files changed (1) hide show
  1. deplacement_dans_env/viewer_pr_env.js +280 -173
deplacement_dans_env/viewer_pr_env.js CHANGED
@@ -1,10 +1,11 @@
1
- // viewer_pr_env.js
2
  // ==============================
3
 
4
  /* -------------------------------------------
5
  Utils
6
  -------------------------------------------- */
7
 
 
8
  async function loadImageAsTexture(url, app) {
9
  return new Promise((resolve, reject) => {
10
  const img = new window.Image();
@@ -72,7 +73,7 @@ async function ensureOrbitScriptsLoaded() {
72
 
73
  window.__PLY_ORBIT_LOADING__ = new Promise((resolve, reject) => {
74
  const s = document.createElement("script");
75
- // Script caméra libre + collisions (garde le nom public "orbitCamera")
76
  s.src = "https://mikafil-viewer-sgos.static.hf.space/deplacement_dans_env/ctrl_camera_pr_env.js";
77
  s.async = true;
78
  s.onload = () => {
@@ -80,7 +81,7 @@ async function ensureOrbitScriptsLoaded() {
80
  resolve();
81
  };
82
  s.onerror = (e) => {
83
- console.error("[viewer.js] Failed to load orbit-camera script", e);
84
  reject(e);
85
  };
86
  document.head.appendChild(s);
@@ -96,41 +97,54 @@ async function ensureOrbitScriptsLoaded() {
96
  let pc;
97
  export let app = null;
98
  let cameraEntity = null;
99
- let modelEntity = null; // gsplat principal (oeuvre)
100
- let envEntity = null; // GLB d'environnement / présentoir
101
  let viewerInitialized = false;
102
  let resizeObserver = null;
 
103
 
104
  // paramètres courants de l'instance
105
  let chosenCameraX, chosenCameraY, chosenCameraZ;
106
- let distanceMin, minAngle, maxAngle, minAzimuth, maxAzimuth, minY;
107
  let modelX, modelY, modelZ, modelScale, modelRotationX, modelRotationY, modelRotationZ;
108
  let presentoirScaleX, presentoirScaleY, presentoirScaleZ;
109
- let sogsUrl, glbUrl;
110
  let color_bg_hex, color_bg, espace_expo_bool;
111
 
 
 
 
 
 
 
112
  /* -------------------------------------------
113
  Initialisation
114
  -------------------------------------------- */
115
 
116
  export async function initializeViewer(config, instanceId) {
117
- // une seule initialisation par import
118
  if (viewerInitialized) return;
119
 
120
  const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
121
  const isMobile = isIOS || /Android/i.test(navigator.userAgent);
122
 
123
  // --- Configuration ---
124
- sogsUrl = config.sogs_json_url || null;
125
- glbUrl = config.glb_url || null;
126
-
127
- // rétro-compat minZoom => distanceMin (conservé pour compat mais non utilisé par la free cam)
128
- distanceMin = config.minZoom !== undefined
129
- ? parseFloat(config.minZoom)
130
- : (config.distanceMin !== undefined ? parseFloat(config.distanceMin) : 1);
131
-
132
- minAngle = parseFloat(config.minAngle ?? "-45");
133
- maxAngle = parseFloat(config.maxAngle ?? "90");
 
 
 
 
 
 
 
134
  minAzimuth = config.minAzimuth !== undefined ? parseFloat(config.minAzimuth) : -360;
135
  maxAzimuth = config.maxAzimuth !== undefined ? parseFloat(config.maxAzimuth) : 360;
136
  minY = config.minY !== undefined ? parseFloat(config.minY) : 0;
@@ -143,10 +157,9 @@ export async function initializeViewer(config, instanceId) {
143
  modelRotationY = config.modelRotationY !== undefined ? parseFloat(config.modelRotationY) : 0;
144
  modelRotationZ = config.modelRotationZ !== undefined ? parseFloat(config.modelRotationZ) : 0;
145
 
146
- // défauts à 1 (et non 0) pour éviter l'invisibilité si manquants
147
- presentoirScaleX = config.presentoirScaleX !== undefined ? parseFloat(config.presentoirScaleX) : 1;
148
- presentoirScaleY = config.presentoirScaleY !== undefined ? parseFloat(config.presentoirScaleY) : 1;
149
- presentoirScaleZ = config.presentoirScaleZ !== undefined ? parseFloat(config.presentoirScaleZ) : 1;
150
 
151
  const cameraX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0;
152
  const cameraY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 2;
@@ -164,6 +177,17 @@ export async function initializeViewer(config, instanceId) {
164
  chosenCameraY = isMobile ? cameraYPhone : cameraY;
165
  chosenCameraZ = isMobile ? cameraZPhone : cameraZ;
166
 
 
 
 
 
 
 
 
 
 
 
 
167
  // --- Prépare le canvas unique à cette instance ---
168
  const canvasId = "canvas-" + instanceId;
169
  const progressDialog = document.getElementById("progress-dialog-" + instanceId);
@@ -180,7 +204,7 @@ export async function initializeViewer(config, instanceId) {
180
  canvas.setAttribute("tabindex", "0");
181
  viewerContainer.insertBefore(canvas, progressDialog);
182
 
183
- // interactions de base (éviter scroll/gestes par défaut sur le canvas)
184
  canvas.style.touchAction = "none";
185
  canvas.style.webkitTouchCallout = "none";
186
  canvas.addEventListener("gesturestart", (e) => e.preventDefault());
@@ -196,33 +220,60 @@ export async function initializeViewer(config, instanceId) {
196
  );
197
  canvas.addEventListener(
198
  "wheel",
199
- (e) => { e.preventDefault(); },
 
 
200
  { passive: false }
201
  );
202
 
203
  // Bloque le scroll page uniquement quand le pointeur est sur le canvas
204
- const scrollKeys = new Set([ "ArrowUp","ArrowDown","ArrowLeft","ArrowRight","PageUp","PageDown","Home","End"," ","Space","Spacebar" ]);
 
 
 
 
 
 
 
 
 
 
 
 
205
  let isPointerOverCanvas = false;
206
  const focusCanvas = () => canvas.focus({ preventScroll: true });
207
 
208
- const onPointerEnter = () => { isPointerOverCanvas = true; focusCanvas(); };
 
 
 
209
  const onPointerLeave = () => {
210
  isPointerOverCanvas = false;
211
  if (document.activeElement === canvas) canvas.blur();
212
  };
213
- const onCanvasBlur = () => { isPointerOverCanvas = false; };
 
 
214
 
215
  canvas.addEventListener("pointerenter", onPointerEnter);
216
  canvas.addEventListener("pointerleave", onPointerLeave);
217
  canvas.addEventListener("mouseenter", onPointerEnter);
218
  canvas.addEventListener("mouseleave", onPointerLeave);
219
  canvas.addEventListener("mousedown", focusCanvas);
220
- canvas.addEventListener("touchstart", () => { focusCanvas(); }, { passive: false });
 
 
 
 
 
 
221
  canvas.addEventListener("blur", onCanvasBlur);
222
 
223
  const onKeyDownCapture = (e) => {
224
  if (!isPointerOverCanvas) return;
225
- if (scrollKeys.has(e.key) || scrollKeys.has(e.code)) e.preventDefault();
 
 
226
  };
227
  window.addEventListener("keydown", onKeyDownCapture, true);
228
 
@@ -231,7 +282,7 @@ export async function initializeViewer(config, instanceId) {
231
  // --- Charge PlayCanvas lib ESM (une par module/instance) ---
232
  if (!pc) {
233
  pc = await import("https://esm.run/playcanvas");
234
- window.pc = pc; // utile pour tooltips.js et debug
235
  }
236
 
237
  // --- Crée l'Application ---
@@ -241,13 +292,15 @@ export async function initializeViewer(config, instanceId) {
241
  twgslUrl: "https://playcanvas.vercel.app/static/lib/twgsl/twgsl.js",
242
  antialias: false
243
  });
244
- device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);
 
 
245
 
246
  const opts = new pc.AppOptions();
247
  opts.graphicsDevice = device;
248
  opts.mouse = new pc.Mouse(canvas);
249
  opts.touch = new pc.TouchDevice(canvas);
250
- opts.keyboard = new pc.Keyboard(canvas); // scoping clavier au canvas
251
  opts.componentSystems = [
252
  pc.RenderComponentSystem,
253
  pc.CameraComponentSystem,
@@ -257,26 +310,35 @@ export async function initializeViewer(config, instanceId) {
257
  pc.CollisionComponentSystem,
258
  pc.RigidbodyComponentSystem
259
  ];
 
260
  opts.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.ScriptHandler, pc.GSplatHandler];
261
 
262
  app = new pc.Application(canvas, opts);
263
  app.setCanvasFillMode(pc.FILLMODE_NONE);
264
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
265
 
 
266
  resizeObserver = new ResizeObserver((entries) => {
267
- entries.forEach((entry) => {
268
- app.resizeCanvas(entry.contentRect.width, entry.contentRect.height);
269
- });
 
 
270
  });
271
  resizeObserver.observe(viewerContainer);
272
 
273
- window.addEventListener("resize", () =>
274
- app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight)
275
- );
 
 
 
276
 
277
  // Nettoyage complet
278
  app.on("destroy", () => {
279
- try { resizeObserver.disconnect(); } catch {}
 
 
280
  if (opts.keyboard && opts.keyboard.detach) opts.keyboard.detach();
281
  window.removeEventListener("keydown", onKeyDownCapture, true);
282
 
@@ -289,139 +351,175 @@ export async function initializeViewer(config, instanceId) {
289
  canvas.removeEventListener("blur", onCanvasBlur);
290
  });
291
 
292
- // --- Enregistre les assets (SAUF orbit script : chargé globalement) ---
293
- const assets = {};
294
- if (sogsUrl) assets.sogs = new pc.Asset("gsplat", "gsplat", { url: sogsUrl });
295
- if (glbUrl) assets.env = new pc.Asset("env", "container", { url: glbUrl });
296
-
297
- for (const k in assets) app.assets.add(assets[k]);
298
 
299
- const loader = new pc.AssetListLoader(Object.values(assets), app.assets);
300
-
301
- // Assure le chargement unique des scripts de caméra
302
  await ensureOrbitScriptsLoaded();
303
 
304
- loader.load(() => {
305
- app.start();
306
- progressDialog.style.display = "none";
307
-
308
- // --- Modèle principal (gsplat) ---
309
- if (assets.sogs) {
310
- modelEntity = new pc.Entity("model");
311
- modelEntity.addComponent("gsplat", { asset: assets.sogs });
312
- modelEntity.setLocalPosition(modelX, modelY, modelZ);
313
- modelEntity.setLocalEulerAngles(modelRotationX, modelRotationY, modelRotationZ);
314
- modelEntity.setLocalScale(modelScale, modelScale, modelScale);
315
- app.root.addChild(modelEntity);
316
- }
317
-
318
- // --- GLB Environnement / Présentoir (optionnel) ---
319
- if (assets.env && assets.env.resource) {
320
- const container = assets.env.resource; // pc.ContainerResource
321
- envEntity = container.instantiateRenderEntity({
322
- castShadows: false,
323
- receiveShadows: true
324
- });
325
 
326
- // Position/rotation/échelle (adaptables)
327
- envEntity.setLocalPosition(0, 0, 0);
328
- envEntity.setLocalEulerAngles(0, 0, 0);
329
- envEntity.setLocalScale(presentoirScaleX, presentoirScaleY, presentoirScaleZ);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
- app.root.addChild(envEntity);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  }
 
 
333
 
334
- // Entité qui sert d'ancrage visuel (lookAt) : l'oeuvre si dispo, sinon l'env
335
- const focusVisual = modelEntity || envEntity;
336
 
337
- // Racine de collision pour la free cam : l'environnement GLB de préférence
338
- const collisionRoot = envEntity || modelEntity || null;
339
 
340
- // --- Caméra + scripts d’input (free cam + collisions) ---
341
- cameraEntity = new pc.Entity("camera");
342
- cameraEntity.addComponent("camera", {
343
- clearColor: new pc.Color(color_bg[0], color_bg[1], color_bg[2], color_bg[3]),
344
- nearClip: 0.001,
345
- farClip: 100
346
- });
347
- cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
348
- if (focusVisual) cameraEntity.lookAt(focusVisual.getPosition());
349
- cameraEntity.addComponent("script");
350
 
351
- // Prépare les attributs : les champs inconnus sont ignorés par le script
352
- const orbitAttrs = {
353
- // Collision root : active le blocage contre le GLB
354
- focusEntity: collisionRoot || undefined,
 
 
 
355
 
356
- // Inertie/angles (pitch clampé), yaw libre
357
- inertiaFactor: 0.2,
358
- pitchAngleMax: maxAngle,
359
- pitchAngleMin: minAngle,
360
 
361
- // Compat (ignorés par le free cam, mais gardés pour ne pas casser la config)
362
- distanceMin: distanceMin,
363
- yawAngleMax: maxAzimuth,
364
- yawAngleMin: minAzimuth,
365
- frameOnStart: false,
 
 
 
 
366
 
367
- // Contraintes position
368
- minY: minY
369
- };
 
 
 
370
 
371
- // Injecte la BBox uniquement si présente dans le config (évite Infinity explicite)
372
- const maybeNum = (v) => (v === undefined || v === null || v === "" ? undefined : parseFloat(v));
373
- const Xmin = maybeNum(config.Xmin);
374
- const Xmax = maybeNum(config.Xmax);
375
- const Ymin = maybeNum(config.Ymin);
376
- const Ymax = maybeNum(config.Ymax);
377
- const Zmin = maybeNum(config.Zmin);
378
- const Zmax = maybeNum(config.Zmax);
379
-
380
- if (Xmin !== undefined) orbitAttrs.Xmin = Xmin;
381
- if (Xmax !== undefined) orbitAttrs.Xmax = Xmax;
382
- if (Ymin !== undefined) orbitAttrs.Ymin = Ymin;
383
- if (Ymax !== undefined) orbitAttrs.Ymax = Ymax;
384
- if (Zmin !== undefined) orbitAttrs.Zmin = Zmin;
385
- if (Zmax !== undefined) orbitAttrs.Zmax = Zmax;
386
-
387
- // Paramètres collision optionnels depuis la config (sinon valeurs par défaut du script)
388
- if (config.collisionRadius !== undefined) orbitAttrs.collisionRadius = parseFloat(config.collisionRadius);
389
- if (config.collisionEpsilon !== undefined) orbitAttrs.collisionEpsilon = parseFloat(config.collisionEpsilon);
390
- if (config.moveSpeed !== undefined) orbitAttrs.moveSpeed = parseFloat(config.moveSpeed);
391
- if (config.strafeSpeed !== undefined) orbitAttrs.strafeSpeed = parseFloat(config.strafeSpeed);
392
- if (config.dollySpeed !== undefined) orbitAttrs.dollySpeed = parseFloat(config.dollySpeed);
393
-
394
- cameraEntity.script.create("orbitCamera", { attributes: orbitAttrs });
395
- cameraEntity.script.create("orbitCameraInputMouse");
396
- cameraEntity.script.create("orbitCameraInputTouch");
397
- cameraEntity.script.create("orbitCameraInputKeyboard", { attributes: { acceleration: 1.0 } });
398
- app.root.addChild(cameraEntity);
399
-
400
- app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight);
401
-
402
- // Reset caméra une fois la première frame prête
403
- app.once("update", () => resetViewerCamera());
404
-
405
- // --- Tooltips (optionnels) ---
406
- try {
407
- if (config.tooltips_url) {
408
- import("./tooltips.js")
409
- .then((tooltipsModule) => {
410
- tooltipsModule.initializeTooltips({
411
- app,
412
- cameraEntity,
413
- modelEntity: focusVisual,
414
- tooltipsUrl: config.tooltips_url,
415
- defaultVisible: !!config.showTooltipsDefault,
416
- moveDuration: config.tooltipMoveDuration || 0.6
417
- });
418
- })
419
- .catch(() => { /* optional */ });
420
  }
421
- } catch (e) { /* optional */ }
422
 
423
- viewerInitialized = true;
424
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  }
426
 
427
  /* -------------------------------------------
@@ -430,34 +528,43 @@ export async function initializeViewer(config, instanceId) {
430
 
431
  export function resetViewerCamera() {
432
  try {
433
- if (!cameraEntity || !app) return;
434
-
435
- // cible visuelle : gsplat prioritaire, sinon glb, sinon (0,0,0)
436
- const targetEntity = modelEntity || envEntity;
437
- const targetPos = targetEntity ? targetEntity.getPosition().clone() : new pc.Vec3(0, 0, 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
- const orbitCam = cameraEntity.script && cameraEntity.script.orbitCamera;
440
- if (!orbitCam) return;
441
 
442
  const tempEnt = new pc.Entity();
443
  tempEnt.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
444
- tempEnt.lookAt(targetPos);
445
 
446
  const dist = new pc.Vec3()
447
- .sub2(new pc.Vec3(chosenCameraX, chosenCameraY, chosenCameraZ), targetPos)
448
  .length();
449
 
450
  cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ);
451
- cameraEntity.lookAt(targetPos);
452
 
453
- // Ces champs existent dans la version "orbit" historique ; notre free cam les ignore,
454
- // mais on conserve l'initialisation pour compat.
455
- if (orbitCam) {
456
- orbitCam._targetDistance = Math.max(distanceMin, dist);
457
- orbitCam._distance = Math.max(distanceMin, dist);
458
- }
459
 
460
- // Recalcule yaw/pitch cibles pour aligner l'orientation
461
  const rot = tempEnt.getRotation();
462
  const fwd = new pc.Vec3();
463
  rot.transformVector(pc.Vec3.FORWARD, fwd);
 
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();
 
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 = () => {
 
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);
 
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;
 
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;
 
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
+ }
184
+ if (config.interactionPixelRatio !== undefined) {
185
+ interactDpr = Math.max(0.75, parseFloat(config.interactionPixelRatio) || interactDpr);
186
+ }
187
+ if (config.idleRestoreDelayMs !== undefined) {
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);
 
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());
 
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
 
 
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 ---
 
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);
324
+ resizeTimeout = setTimeout(() => {
325
+ app.resizeCanvas(entries[0].contentRect.width, entries[0].contentRect.height);
326
+ }, 60);
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
 
 
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) {
439
+ 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);
447
+ idleTimer = setTimeout(() => {
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
  /* -------------------------------------------
 
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);