MikaFil commited on
Commit
5cfa495
·
verified ·
1 Parent(s): 8ca2873

Update viewer_ar_ios.js

Browse files
Files changed (1) hide show
  1. viewer_ar_ios.js +117 -274
viewer_ar_ios.js CHANGED
@@ -1,89 +1,75 @@
 
 
1
  /* viewer_ar_ios.js — AR PlayCanvas + GLB/USDZ via config.json
2
  - Lit config.json (data-config) => { "glb_url": "...", "usdz_url": "..." }
3
- - iOS : AR Quick Look (USDZ) avec #allowsContentScaling=0 (pas de zoom) + bouton “Lancer l’AR”
4
  - Android/Desktop : WebXR AR (plans HORIZONTAUX uniquement) + slider yaw + blob shadow
5
- - Message de démarrage centré & plus grand sur iOS ET Android
6
  - Éclairage PBR par défaut (sans WebXR light estimation)
7
  - Monté dans un conteneur choisi (data-mount="#ar-mount") pour Squarespace
8
  */
9
 
10
  (function () {
11
- // ============ Utils: script tag / config / platform ============
12
  function getCurrentScript() {
13
- // Robuste même si document.currentScript n'est pas dispo
14
  return document.currentScript || (function () {
15
  var scripts = document.getElementsByTagName('script');
16
  return scripts[scripts.length - 1] || null;
17
  })();
18
  }
19
-
20
  function findConfigUrl() {
21
  var el = getCurrentScript();
22
  if (!el) return null;
23
  var url = el.getAttribute('data-config');
24
  return url || null;
25
  }
26
-
27
  function isIOS() {
28
  return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
29
  }
30
-
31
  function timeout(ms) {
32
- return new Promise(function (_res, rej) {
33
- setTimeout(function () { rej(new Error("timeout")); }, ms);
34
- });
35
  }
36
-
37
  async function loadConfigJson(url) {
38
  if (!url) return null;
39
  try {
40
  var resp = await fetch(url, { cache: 'no-store' });
41
  if (!resp.ok) throw new Error("HTTP " + resp.status);
42
- var json = await resp.json();
43
- return json;
44
  } catch (e) {
45
  console.error("Erreur chargement config.json:", e);
46
  return null;
47
  }
48
  }
49
 
50
- // ============ PlayCanvas version fixée (Android/Desktop) ============
51
  var PC_VERSION = "2.11.7";
52
  var PC_URLS = {
53
  esm: ["https://cdn.jsdelivr.net/npm/playcanvas@" + PC_VERSION + "/build/playcanvas.min.mjs"],
54
  umd: ["https://cdn.jsdelivr.net/npm/playcanvas@" + PC_VERSION + "/build/playcanvas.min.js"]
55
  };
56
-
57
  async function loadPlayCanvasRobust(opts) {
58
  opts = opts || {};
59
  var esmFirst = (typeof opts.esmFirst === "boolean") ? opts.esmFirst : true;
60
  var loadTimeoutMs = (typeof opts.loadTimeoutMs === "number") ? opts.loadTimeoutMs : 15000;
61
-
62
  if (window.pc && window.pc.Application) return window.pc;
63
 
64
  async function tryESM() {
65
  for (var i = 0; i < PC_URLS.esm.length; i++) {
66
- var url = PC_URLS.esm[i];
67
  try {
68
- var mod = await Promise.race([import(/* @vite-ignore */ url), timeout(loadTimeoutMs)]);
69
  var ns = (mod && (mod.pc || mod["default"])) || mod;
70
- if (ns && ns.Application) {
71
- if (!window.pc) window.pc = ns;
72
- return window.pc;
73
- }
74
- } catch (_e) { /* continue */ }
75
  }
76
  throw new Error("ESM failed");
77
  }
78
-
79
  async function tryUMD() {
80
  for (var j = 0; j < PC_URLS.umd.length; j++) {
81
- var url2 = PC_URLS.umd[j];
82
  try {
83
  await Promise.race([
84
  new Promise(function (res, rej) {
85
  var s = document.createElement("script");
86
- s.src = url2;
87
  s.async = true;
88
  s.onload = function () { res(); };
89
  s.onerror = function () { rej(new Error("script error")); };
@@ -92,11 +78,10 @@
92
  timeout(loadTimeoutMs)
93
  ]);
94
  if (window.pc && window.pc.Application) return window.pc;
95
- } catch (_e) { /* continue */ }
96
  }
97
  throw new Error("UMD failed");
98
  }
99
-
100
  try {
101
  if (esmFirst) return await tryESM();
102
  return await tryUMD();
@@ -106,23 +91,23 @@
106
  }
107
  }
108
 
109
- // ============ UI / Overlay commun ============
110
- // Style minimal intégré : toasts, message centré, bouton “Lancer l’AR” (iOS & Android).
111
  var css = [
112
- /* Toast (bas) */
113
  ".pc-ar-msg{position:fixed;left:50%;transform:translateX(-50%);bottom:16px;z-index:10002;padding:10px 14px;background:rgba(0,0,0,.65);color:#fff;border-radius:12px;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:14px;line-height:1.3;text-align:center;max-width:min(90vw,640px);box-shadow:0 6px 20px rgba(0,0,0,.25);backdrop-filter:blur(4px);pointer-events:none}",
114
- /* Variante centrée + plus grande pour le message “Modèle chargé …” */
115
  ".pc-ar-msg.pc-ar-msg--centerBig{top:50%;bottom:auto;transform:translate(-50%,-50%);font-size:18px;padding:14px 18px;max-width:min(90vw,720px)}",
116
 
117
- /* Bouton “Lancer l’AR” */
 
 
118
  "#ar-launch-btn{position:fixed;left:50%;transform:translateX(-50%);bottom:72px;z-index:10003;pointer-events:auto;appearance:none;border:none;border-radius:14px;padding:12px 18px;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:15px;font-weight:600;color:#fff;background:rgba(0,0,0,0.85);box-shadow:0 6px 18px rgba(0,0,0,.25);backdrop-filter:blur(6px);cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent}",
119
  "#ar-launch-btn:active{transform:translateX(-50%) scale(0.98)}",
120
  "#ar-launch-btn:disabled{opacity:.5;cursor:not-allowed}",
121
 
122
- /* Ancre Quick Look cachée (iOS) */
123
- "#ios-quicklook-anchor{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden!important;clip-path:inset(50%)!important;pointer-events:none!important}",
124
-
125
- "#xr-overlay-root{position:fixed;inset:0;z-index:10001;pointer-events:none}",
126
 
127
  /* --- NE PAS MODIFIER : panneau Rotation (WebXR) --- */
128
  ".ar-ui{position:absolute;right:12px;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.55);color:#fff;padding:12px 10px;border-radius:16px;width:56px;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;pointer-events:auto;display:flex;flex-direction:column;align-items:center;gap:8px;box-shadow:0 6px 18px rgba(0,0,0,.25);backdrop-filter:blur(6px);touch-action:none}",
@@ -139,37 +124,25 @@
139
 
140
  function ensureOverlayRoot() {
141
  var r = document.getElementById("xr-overlay-root");
142
- if (!r) {
143
- r = document.createElement("div");
144
- r.id = "xr-overlay-root";
145
- document.body.appendChild(r);
146
- }
147
  return r;
148
  }
149
  var overlayRoot = ensureOverlayRoot();
150
 
151
- // ------ Système de message : toast (bas) OU centré agrandi ------
152
  function getMessageEl() {
153
  var el = overlayRoot.querySelector(".pc-ar-msg");
154
- if (!el) {
155
- el = document.createElement("div");
156
- el.className = "pc-ar-msg";
157
- overlayRoot.appendChild(el);
158
- }
159
  return el;
160
  }
161
- function messageToast(text) {
162
- var el = getMessageEl();
163
- el.classList.remove("pc-ar-msg--centerBig");
164
- el.textContent = text;
165
  }
166
- function messageCenterBig(text) {
167
- var el = getMessageEl();
168
- el.classList.add("pc-ar-msg--centerBig");
169
- el.textContent = text;
170
  }
171
 
172
- // iOS Quick Look URL (empêche le pinch to scale)
173
  function buildQuickLookHref(usdzUrl) {
174
  try {
175
  var u = new URL(usdzUrl, window.location.href);
@@ -182,92 +155,49 @@
182
  return usdzUrl + (usdzUrl.indexOf('#') >= 0 ? '&' : '#') + 'allowsContentScaling=0';
183
  }
184
  }
185
-
186
- // Bouton “Lancer l’AR” (visible) + ancre Quick Look (cachée) pour iOS
187
- function ensureIOSLaunchUI(usdzUrl) {
188
- // Ancre cachée qui déclenche Quick Look
189
- var anchor = document.getElementById("ios-quicklook-anchor");
190
- if (!anchor) {
191
- anchor = document.createElement("a");
192
- anchor.id = "ios-quicklook-anchor";
193
- anchor.setAttribute("rel", "ar");
194
- document.body.appendChild(anchor);
195
- }
196
- if (usdzUrl) anchor.setAttribute("href", buildQuickLookHref(usdzUrl));
197
-
198
- // Bouton visible “Lancer l’AR”
199
- var btn = document.getElementById("ar-launch-btn");
200
- if (!btn) {
201
- btn = document.createElement("button");
202
- btn.id = "ar-launch-btn";
203
- btn.type = "button";
204
- btn.textContent = "Lancer l’AR";
205
- // Évite la propagation d’events vers la scène
206
- btn.addEventListener("pointerdown", function (e) { e.stopPropagation(); }, { passive: true });
207
- btn.addEventListener("click", function (e) { e.stopPropagation(); }, false);
208
- document.body.appendChild(btn);
209
  }
210
-
211
- // Clic => simulateur de clic sur l’ancre rel="ar"
212
- btn.onclick = function (e) {
213
- e.preventDefault();
214
- e.stopPropagation();
215
- if (anchor && anchor.href) anchor.click();
216
- };
217
-
218
- return { btn: btn, anchor: anchor };
219
  }
220
 
221
- // ============ Canvas monté dans un conteneur contrôlé (Squarespace) ============
222
  function ensureCanvas() {
223
  var scriptEl = getCurrentScript();
224
  var mountSel = scriptEl && scriptEl.getAttribute('data-mount');
225
  var desiredHeight = (scriptEl && scriptEl.getAttribute('data-height')) || '70vh';
226
 
227
- // Trouve le conteneur cible
228
  var mountEl = null;
229
  if (mountSel) mountEl = document.querySelector(mountSel);
230
- if (!mountEl) {
231
- // fallback : tout en haut du body (pour éviter "sous le footer")
232
- mountEl = document.createElement('div');
233
- mountEl.id = 'ar-mount-fallback';
234
- document.body.insertBefore(mountEl, document.body.firstChild);
235
- }
236
 
237
- // Style du conteneur
238
- var mountStyle = mountEl.style;
239
- if (!mountStyle.position) mountStyle.position = 'relative';
240
- mountStyle.width = mountStyle.width || '100%';
241
- mountStyle.minHeight = mountStyle.minHeight || desiredHeight;
242
- mountStyle.touchAction = mountStyle.touchAction || 'manipulation';
243
- mountStyle.webkitTapHighlightColor = 'transparent';
244
 
245
- // Réutilise ou crée le canvas
246
  var canvas = mountEl.querySelector('#application-canvas');
247
- if (!canvas) {
248
- canvas = document.createElement('canvas');
249
- canvas.id = 'application-canvas';
250
- mountEl.appendChild(canvas);
251
- }
252
 
253
- // Le canvas remplit le conteneur
254
  var cs = canvas.style;
255
- cs.position = 'absolute';
256
- cs.left = '0';
257
- cs.top = '0';
258
- cs.width = '100%';
259
- cs.height = '100%';
260
- cs.display = 'block';
261
-
262
- // Optionnel : ramener le viewport sur le viewer
263
- try {
264
- mountEl.scrollIntoView({ behavior: 'instant', block: 'start' });
265
- } catch (_e) {}
266
 
 
267
  return canvas;
268
  }
269
 
270
- // ---------- Panneau “Rotation” (inchangé pour WebXR) ----------
271
  function ensureSliderUI() {
272
  var p = overlayRoot.querySelector(".ar-ui");
273
  if (p) return p;
@@ -285,23 +215,19 @@
285
  return p;
286
  }
287
 
288
- // ============ Boot : charge config, branche iOS vs Android/Desktop ============
289
  (async function () {
290
  var cfgUrl = findConfigUrl();
291
  var cfg = await loadConfigJson(cfgUrl);
292
  var GLB_URL = (cfg && typeof cfg.glb_url === "string" && cfg.glb_url)
293
  ? cfg.glb_url
294
  : "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/AR/tests/danae_no_metallic.glb";
295
- var USDZ_URL = (cfg && typeof cfg.usdz_url === "string" && cfg.usdz_url)
296
- ? cfg.usdz_url
297
- : null;
298
 
299
- // --------- iOS : Quick Look + message centré + bouton “Lancer l’AR” ---------
300
  if (isIOS()) {
301
  if (USDZ_URL) {
302
- // UI iOS (ancre rel=ar + bouton visible)
303
- ensureIOSLaunchUI(USDZ_URL);
304
- // Message de démarrage centré et plus grand (même UX que WebXR)
305
  messageCenterBig("Modèle chargé. Appuyez sur « Lancer l’AR » pour démarrer.");
306
  } else {
307
  messageToast("iOS détecté, mais aucun 'usdz_url' dans config.json.");
@@ -309,7 +235,7 @@
309
  return;
310
  }
311
 
312
- // --------- Android / Desktop : WebXR ---------
313
  try {
314
  await loadPlayCanvasRobust({ esmFirst: true, loadTimeoutMs: 15000 });
315
  } catch (e) {
@@ -320,11 +246,10 @@
320
  initARApp(GLB_URL);
321
  })();
322
 
323
- // ============ App Android/Desktop (WebXR) ============
324
  function initARApp(GLB_URL) {
325
  var pc = window.pc;
326
 
327
- // Canvas + UI
328
  var canvas = ensureCanvas();
329
  var sliderPanel = ensureSliderUI();
330
  var rotTrack = sliderPanel.querySelector("#ar-rotY-wrap");
@@ -334,7 +259,6 @@
334
 
335
  window.focus();
336
 
337
- // --- Application PlayCanvas ---
338
  var app = new pc.Application(canvas, {
339
  mouse: new pc.Mouse(canvas),
340
  touch: new pc.TouchDevice(canvas),
@@ -350,13 +274,13 @@
350
  app.on("destroy", function () { window.removeEventListener("resize", onResize); });
351
  app.start();
352
 
353
- // --- Rendu / PBR defaults ---
354
  app.scene.gammaCorrection = pc.GAMMA_SRGB;
355
  app.scene.toneMapping = pc.TONEMAP_ACES;
356
  app.scene.exposure = 1;
357
  app.scene.ambientLight = new pc.Color(1, 1, 1);
358
 
359
- // --- Caméra + lumière directionnelle ---
360
  var camera = new pc.Entity("Camera");
361
  camera.addComponent("camera", { clearColor: new pc.Color(0, 0, 0, 0), farClip: 10000 });
362
  app.root.addChild(camera);
@@ -366,61 +290,47 @@
366
  mainLight.setLocalEulerAngles(45, 30, 0);
367
  app.root.addChild(mainLight);
368
 
369
- // --- Réticule ---
370
  var reticleMat = new pc.StandardMaterial();
371
  reticleMat.diffuse = new pc.Color(0.2, 0.8, 1.0);
372
  reticleMat.opacity = 0.85;
373
  reticleMat.blendType = pc.BLEND_NORMAL;
374
  reticleMat.update();
375
-
376
  var reticle = new pc.Entity("Reticle");
377
  reticle.addComponent("render", { type: "torus", material: reticleMat });
378
  reticle.setLocalScale(0.12, 0.005, 0.12);
379
  reticle.enabled = false;
380
  app.root.addChild(reticle);
381
 
382
- // --- Conteneur modèle ---
383
  var modelRoot = new pc.Entity("ModelRoot");
384
  modelRoot.enabled = false;
385
  app.root.addChild(modelRoot);
386
 
387
- var modelLoaded = false;
388
- var placedOnce = false;
389
 
390
- // ===== Ombre de contact blob” =====
391
  var blobShadowEntity = null;
392
- var BLOB_SIZE = 0.4;
393
- var BLOB_OFFSET_Y = 0.005;
394
 
395
  function makeBlobTexture(appRef, size) {
396
  var s = size || 256;
397
- var cvs = document.createElement('canvas');
398
- cvs.width = cvs.height = s;
399
  var ctx = cvs.getContext('2d');
400
  var r = s * 0.45;
401
- var grd = ctx.createRadialGradient(s / 2, s / 2, r * 0.2, s / 2, s / 2, r);
402
- grd.addColorStop(0, 'rgba(0,0,0,0.5)');
403
- grd.addColorStop(1, 'rgba(0,0,0,0.0)');
404
- ctx.fillStyle = grd;
405
- ctx.fillRect(0, 0, s, s);
406
-
407
  var tex = new pc.Texture(appRef.graphicsDevice, {
408
- width: s,
409
- height: s,
410
- format: pc.PIXELFORMAT_R8_G8_B8_A8,
411
- mipmaps: true,
412
- magFilter: pc.FILTER_LINEAR,
413
- minFilter: pc.FILTER_LINEAR_MIPMAP_LINEAR
414
  });
415
- tex.setSource(cvs);
416
- return tex;
417
  }
418
-
419
  function createBlobShadowAt(pos, rot) {
420
  var tex = makeBlobTexture(app, 256);
421
-
422
  var blobMat = new pc.StandardMaterial();
423
- blobMat.diffuse = new pc.Color(0, 0, 0);
424
  blobMat.opacity = 1.0;
425
  blobMat.opacityMap = tex;
426
  blobMat.opacityMapChannel = 'a';
@@ -429,27 +339,20 @@
429
  blobMat.depthWrite = false;
430
  blobMat.alphaTest = 0;
431
  blobMat.update();
432
-
433
  var e = new pc.Entity("BlobShadow");
434
  e.addComponent("render", { type: "plane", castShadows: false, receiveShadows: false });
435
  e.render.material = blobMat;
436
-
437
  e.setPosition(pos.x, pos.y + BLOB_OFFSET_Y, pos.z);
438
  e.setRotation(rot);
439
  e.setLocalScale(BLOB_SIZE, 1, BLOB_SIZE);
440
-
441
  app.root.addChild(e);
442
  return e;
443
  }
444
 
445
- // Euler de base (évite inversions)
446
- var baseEulerX = 0;
447
- var baseEulerZ = 0;
448
-
449
- // Rotation via slider (0..360, 360 en haut / 0 en bas)
450
  var rotationYDeg = 0;
451
  function clamp360(d) { return Math.max(0, Math.min(360, d)); }
452
-
453
  function updateKnobFromY(yDeg) {
454
  var t = 1 - (yDeg / 360);
455
  rotThumb.style.top = String(t * 100) + "%";
@@ -462,31 +365,25 @@
462
  modelRoot.setEulerAngles(baseEulerX, y, baseEulerZ);
463
  updateKnobFromY(y);
464
  }
465
-
466
  function updateBlobPositionUnder(pos, rotLikePlane) {
467
  if (!blobShadowEntity) return;
468
  blobShadowEntity.setPosition(pos.x, pos.y + BLOB_OFFSET_Y, pos.z);
469
  if (rotLikePlane) blobShadowEntity.setRotation(rotLikePlane);
470
  }
471
 
472
- // Chargement GLB (depuis config.json)
473
  app.assets.loadFromUrl(GLB_URL, "container", function (err, asset) {
474
  if (err) { console.error(err); messageToast("Échec du chargement du modèle GLB."); return; }
475
  var instance = asset.resource.instantiateRenderEntity({ castShadows: true, receiveShadows: false });
476
  modelRoot.addChild(instance);
477
- modelRoot.setLocalScale(1, 1, 1);
478
 
479
- // Fix matériaux simples
480
  var renders = instance.findComponents('render');
481
  for (var ri = 0; ri < renders.length; ri++) {
482
- var r = renders[ri];
483
- r.castShadows = true;
484
  for (var mi = 0; mi < r.meshInstances.length; mi++) {
485
- var mat = r.meshInstances[mi].material;
486
- if (!mat) continue;
487
- if (mat.diffuse && (mat.diffuse.r !== 1 || mat.diffuse.g !== 1 || mat.diffuse.b !== 1)) {
488
- mat.diffuse.set(1, 1, 1);
489
- }
490
  if (typeof mat.useSkybox !== "undefined") mat.useSkybox = true;
491
  mat.update();
492
  }
@@ -496,62 +393,45 @@
496
  baseEulerX = initE.x; baseEulerZ = initE.z;
497
 
498
  modelLoaded = true;
499
- // Message de démarrage centré & plus grand (même UX que iOS)
500
  messageCenterBig("Modèle chargé. Touchez l’écran pour démarrer l’AR.");
501
  });
502
 
503
  if (!app.xr.supported) { messageToast("WebXR n’est pas supporté sur cet appareil."); return; }
504
 
505
- // ---------- Slider fiable (pointer events en capture) ----------
506
- var isUIInteracting = false;
507
- var isTrackDragging = false;
508
- var activePointerId = null;
509
-
510
  function insideTrack(target) { return rotTrack.contains(target); }
511
  function degFromPointer(e) {
512
  var rect = rotTrack.getBoundingClientRect();
513
  var y = (e.clientY != null) ? e.clientY : ((e.touches && e.touches[0] && e.touches[0].clientY) || 0);
514
- var ratio = (y - rect.top) / rect.height;
515
- var t = Math.max(0, Math.min(1, ratio));
516
- return (1 - t) * 360; // 360 en haut, 0 en bas
517
  }
518
-
519
  function onPointerDownCapture(e) {
520
  if (!insideTrack(e.target)) return;
521
- isUIInteracting = true;
522
- isTrackDragging = true;
523
  activePointerId = (e.pointerId != null) ? e.pointerId : 1;
524
- if (rotTrack.setPointerCapture) {
525
- try { rotTrack.setPointerCapture(activePointerId); } catch (_er) {}
526
- }
527
  applyRotationY(degFromPointer(e));
528
- e.preventDefault();
529
- e.stopPropagation();
530
  }
531
  function onPointerMoveCapture(e) {
532
  if (!isTrackDragging || ((e.pointerId != null ? e.pointerId : 1) !== activePointerId)) return;
533
  applyRotationY(degFromPointer(e));
534
- e.preventDefault();
535
- e.stopPropagation();
536
  }
537
  function endDrag(e) {
538
  if (!isTrackDragging || ((e.pointerId != null ? e.pointerId : 1) !== activePointerId)) return;
539
- isTrackDragging = false;
540
- isUIInteracting = false;
541
- if (rotTrack.releasePointerCapture) {
542
- try { rotTrack.releasePointerCapture(activePointerId); } catch (_er) {}
543
- }
544
- activePointerId = null;
545
- e.preventDefault();
546
- e.stopPropagation();
547
  }
548
-
549
  document.addEventListener("pointerdown", onPointerDownCapture, { capture: true, passive: false });
550
  document.addEventListener("pointermove", onPointerMoveCapture, { capture: true, passive: false });
551
  document.addEventListener("pointerup", endDrag, { capture: true, passive: false });
552
  document.addEventListener("pointercancel", endDrag, { capture: true, passive: false });
553
 
554
- // --- Démarrage AR (tap écran)
555
  function activateAR() {
556
  if (!app.xr.isAvailable(pc.XRTYPE_AR)) { messageToast("AR immersive indisponible sur cet appareil."); return; }
557
  if (!app.xr.domOverlay) app.xr.domOverlay = {};
@@ -560,34 +440,26 @@
560
  requiredFeatures: ["hit-test", "dom-overlay"],
561
  domOverlay: { root: app.xr.domOverlay.root },
562
  callback: function (err) {
563
- if (err) {
564
- console.error("Échec du démarrage AR :", err);
565
- messageToast("Échec du démarrage AR : " + (err.message || err));
566
- }
567
  }
568
  });
569
  }
570
- // Tap écran → démarre l'AR (si pas sur le slider)
571
  app.mouse.on("mousedown", function () { if (!app.xr.active && !isUIInteracting) activateAR(); });
572
  if (app.touch) {
573
  app.touch.on("touchend", function (evt) {
574
  if (!app.xr.active && !isUIInteracting) activateAR();
575
- evt.event.preventDefault();
576
- evt.event.stopPropagation();
577
  });
578
  }
579
  app.keyboard.on("keydown", function (evt) { if (evt.key === pc.KEY_ESCAPE && app.xr.active) app.xr.end(); });
580
 
581
  // Hit-test HORIZONTAL uniquement
582
- var TMP_IN = new pc.Vec3(0, 1, 0);
583
- var TMP_OUT = new pc.Vec3();
584
  function isHorizontalUpFacing(rot, minDot) {
585
  var md = (typeof minDot === "number") ? minDot : 0.75;
586
  rot.transformVector(TMP_IN, TMP_OUT);
587
- return TMP_OUT.y >= md; // normale proche de +Y
588
  }
589
-
590
- // Hit Test global
591
  app.xr.hitTest.on("available", function () {
592
  app.xr.hitTest.start({
593
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
@@ -595,27 +467,16 @@
595
  if (err) { messageToast("Le AR hit test n’a pas pu démarrer."); return; }
596
  hitSource.on("result", function (pos, rot) {
597
  if (!isHorizontalUpFacing(rot)) return;
598
-
599
- reticle.enabled = true;
600
- reticle.setPosition(pos);
601
- reticle.setRotation(rot);
602
 
603
  if (modelLoaded && !placedOnce) {
604
- modelRoot.enabled = true;
605
- modelRoot.setPosition(pos);
606
-
607
- // Ombre de contact
608
  blobShadowEntity = createBlobShadowAt(pos, rot);
609
 
610
- var e = new pc.Vec3();
611
- rot.getEulerAngles(e);
612
- var y0 = ((e.y % 360) + 360) % 360;
613
- applyRotationY(y0);
614
 
615
- placedOnce = true;
616
- rotRangeInput.disabled = false;
617
-
618
- // Après placement, toasts bas classiques
619
  messageToast("Objet placé. Glissez pour déplacer, tournez-le avec le slider →");
620
  }
621
  });
@@ -623,26 +484,22 @@
623
  });
624
  });
625
 
626
- // Déplacement XR (drag) — ignoré si UI active
627
  var isModelDragging = false;
628
  app.xr.input.on("add", function (inputSource) {
629
  inputSource.on("selectstart", function () {
630
  if (isUIInteracting) return;
631
  if (!placedOnce || !modelLoaded) return;
632
-
633
  inputSource.hitTestStart({
634
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
635
  callback: function (err, transientSource) {
636
  if (err) return;
637
  isModelDragging = true;
638
-
639
  transientSource.on("result", function (pos, rot) {
640
  if (!isModelDragging) return;
641
  if (!isHorizontalUpFacing(rot)) return;
642
- modelRoot.setPosition(pos);
643
- updateBlobPositionUnder(pos, rot);
644
  });
645
-
646
  transientSource.once("remove", function () { isModelDragging = false; });
647
  }
648
  });
@@ -650,55 +507,40 @@
650
  inputSource.on("selectend", function () { isModelDragging = false; });
651
  });
652
 
653
- // Desktop : rotation souris (ignore si UI)
654
- var isRotateMode = false;
655
- var lastMouseX = 0;
656
  var ROTATE_SENSITIVITY = 0.25;
657
  app.mouse.on("mousedown", function (e) {
658
  if (!app.xr.active || !placedOnce || isUIInteracting) return;
659
- if (e.button === 0 && !e.shiftKey) {
660
- isModelDragging = true;
661
- } else if (e.button === 2 || (e.button === 0 && e.shiftKey)) {
662
- isRotateMode = true;
663
- lastMouseX = e.x;
664
- }
665
  });
666
  app.mouse.on("mousemove", function (e) {
667
  if (!app.xr.active || !placedOnce || isUIInteracting) return;
668
  if (isModelDragging) {
669
  if (reticle.enabled) {
670
- var p = reticle.getPosition();
671
- modelRoot.setPosition(p);
672
- updateBlobPositionUnder(p, null);
673
  }
674
  } else if (isRotateMode && modelRoot.enabled) {
675
- var dx = e.x - lastMouseX;
676
- lastMouseX = e.x;
677
  applyRotationY(rotationYDeg + dx * ROTATE_SENSITIVITY);
678
  }
679
  });
680
  app.mouse.on("mouseup", function () { isModelDragging = false; isRotateMode = false; });
681
  window.addEventListener("contextmenu", function (e) { e.preventDefault(); });
682
 
683
- // Slider (accessibilité clavier)
684
  rotRangeInput.disabled = true;
685
  rotRangeInput.addEventListener("input", function (e) {
686
  if (!modelRoot.enabled) return;
687
- var v = parseFloat(e.target.value || "0");
688
- applyRotationY(v);
689
  }, { passive: true });
690
 
691
- // AR events
692
- app.xr.on("start", function () {
693
- messageToast("Session AR démarrée. Visez le sol pour détecter un plan…");
694
- reticle.enabled = true;
695
- });
696
  app.xr.on("end", function () {
697
  messageToast("Session AR terminée.");
698
- reticle.enabled = false;
699
- isModelDragging = false;
700
- isRotateMode = false;
701
- rotRangeInput.disabled = true;
702
  });
703
  app.xr.on("available:" + pc.XRTYPE_AR, function (a) {
704
  if (!a) messageToast("AR immersive indisponible.");
@@ -711,3 +553,4 @@
711
  else messageToast("Chargement du modèle…");
712
  }
713
  })();
 
 
1
+ <!-- viewer_ar_ios.js -->
2
+ <script>
3
  /* viewer_ar_ios.js — AR PlayCanvas + GLB/USDZ via config.json
4
  - Lit config.json (data-config) => { "glb_url": "...", "usdz_url": "..." }
5
+ - iOS : AR Quick Look (USDZ) sans zoom, **ouverture directe via <a rel="ar">**
6
  - Android/Desktop : WebXR AR (plans HORIZONTAUX uniquement) + slider yaw + blob shadow
7
+ - Message de démarrage centré & plus grand (iOS ET Android)
8
  - Éclairage PBR par défaut (sans WebXR light estimation)
9
  - Monté dans un conteneur choisi (data-mount="#ar-mount") pour Squarespace
10
  */
11
 
12
  (function () {
13
+ // ===================== Utilitaires: script/config/plateforme =====================
14
  function getCurrentScript() {
 
15
  return document.currentScript || (function () {
16
  var scripts = document.getElementsByTagName('script');
17
  return scripts[scripts.length - 1] || null;
18
  })();
19
  }
 
20
  function findConfigUrl() {
21
  var el = getCurrentScript();
22
  if (!el) return null;
23
  var url = el.getAttribute('data-config');
24
  return url || null;
25
  }
 
26
  function isIOS() {
27
  return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
28
  }
 
29
  function timeout(ms) {
30
+ return new Promise(function (_res, rej) { setTimeout(function () { rej(new Error("timeout")); }, ms); });
 
 
31
  }
 
32
  async function loadConfigJson(url) {
33
  if (!url) return null;
34
  try {
35
  var resp = await fetch(url, { cache: 'no-store' });
36
  if (!resp.ok) throw new Error("HTTP " + resp.status);
37
+ return await resp.json();
 
38
  } catch (e) {
39
  console.error("Erreur chargement config.json:", e);
40
  return null;
41
  }
42
  }
43
 
44
+ // ===================== PlayCanvas (Android/Desktop) =====================
45
  var PC_VERSION = "2.11.7";
46
  var PC_URLS = {
47
  esm: ["https://cdn.jsdelivr.net/npm/playcanvas@" + PC_VERSION + "/build/playcanvas.min.mjs"],
48
  umd: ["https://cdn.jsdelivr.net/npm/playcanvas@" + PC_VERSION + "/build/playcanvas.min.js"]
49
  };
 
50
  async function loadPlayCanvasRobust(opts) {
51
  opts = opts || {};
52
  var esmFirst = (typeof opts.esmFirst === "boolean") ? opts.esmFirst : true;
53
  var loadTimeoutMs = (typeof opts.loadTimeoutMs === "number") ? opts.loadTimeoutMs : 15000;
 
54
  if (window.pc && window.pc.Application) return window.pc;
55
 
56
  async function tryESM() {
57
  for (var i = 0; i < PC_URLS.esm.length; i++) {
 
58
  try {
59
+ var mod = await Promise.race([import(/* @vite-ignore */ PC_URLS.esm[i]), timeout(loadTimeoutMs)]);
60
  var ns = (mod && (mod.pc || mod["default"])) || mod;
61
+ if (ns && ns.Application) { if (!window.pc) window.pc = ns; return window.pc; }
62
+ } catch (_e) {}
 
 
 
63
  }
64
  throw new Error("ESM failed");
65
  }
 
66
  async function tryUMD() {
67
  for (var j = 0; j < PC_URLS.umd.length; j++) {
 
68
  try {
69
  await Promise.race([
70
  new Promise(function (res, rej) {
71
  var s = document.createElement("script");
72
+ s.src = PC_URLS.umd[j];
73
  s.async = true;
74
  s.onload = function () { res(); };
75
  s.onerror = function () { rej(new Error("script error")); };
 
78
  timeout(loadTimeoutMs)
79
  ]);
80
  if (window.pc && window.pc.Application) return window.pc;
81
+ } catch (_e) {}
82
  }
83
  throw new Error("UMD failed");
84
  }
 
85
  try {
86
  if (esmFirst) return await tryESM();
87
  return await tryUMD();
 
91
  }
92
  }
93
 
94
+ // ===================== UI / Overlay (styles minimaux intégrés) =====================
 
95
  var css = [
96
+ /* Toast en bas */
97
  ".pc-ar-msg{position:fixed;left:50%;transform:translateX(-50%);bottom:16px;z-index:10002;padding:10px 14px;background:rgba(0,0,0,.65);color:#fff;border-radius:12px;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:14px;line-height:1.3;text-align:center;max-width:min(90vw,640px);box-shadow:0 6px 20px rgba(0,0,0,.25);backdrop-filter:blur(4px);pointer-events:none}",
98
+ /* Variante centrée & plus grande (message de démarrage) */
99
  ".pc-ar-msg.pc-ar-msg--centerBig{top:50%;bottom:auto;transform:translate(-50%,-50%);font-size:18px;padding:14px 18px;max-width:min(90vw,720px)}",
100
 
101
+ "#xr-overlay-root{position:fixed;inset:0;z-index:10001;pointer-events:none}",
102
+
103
+ /* Bouton Android/Desktop (optionnel si tu veux un bouton aussi hors-iOS) */
104
  "#ar-launch-btn{position:fixed;left:50%;transform:translateX(-50%);bottom:72px;z-index:10003;pointer-events:auto;appearance:none;border:none;border-radius:14px;padding:12px 18px;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:15px;font-weight:600;color:#fff;background:rgba(0,0,0,0.85);box-shadow:0 6px 18px rgba(0,0,0,.25);backdrop-filter:blur(6px);cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent}",
105
  "#ar-launch-btn:active{transform:translateX(-50%) scale(0.98)}",
106
  "#ar-launch-btn:disabled{opacity:.5;cursor:not-allowed}",
107
 
108
+ /* Lien Quick Look iOS — visuel type bouton */
109
+ "a.ar-launch-link{position:fixed;left:50%;transform:translateX(-50%);bottom:72px;z-index:10003;pointer-events:auto;display:inline-block;text-decoration:none;text-align:center;border-radius:14px;padding:12px 18px;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:15px;font-weight:600;color:#fff;background:rgba(0,0,0,0.85);box-shadow:0 6px 18px rgba(0,0,0,.25);backdrop-filter:blur(6px);-webkit-tap-highlight-color:transparent}",
110
+ "a.ar-launch-link:active{transform:translateX(-50%) scale(0.98)}",
 
111
 
112
  /* --- NE PAS MODIFIER : panneau Rotation (WebXR) --- */
113
  ".ar-ui{position:absolute;right:12px;top:50%;transform:translateY(-50%);background:rgba(0,0,0,0.55);color:#fff;padding:12px 10px;border-radius:16px;width:56px;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;pointer-events:auto;display:flex;flex-direction:column;align-items:center;gap:8px;box-shadow:0 6px 18px rgba(0,0,0,.25);backdrop-filter:blur(6px);touch-action:none}",
 
124
 
125
  function ensureOverlayRoot() {
126
  var r = document.getElementById("xr-overlay-root");
127
+ if (!r) { r = document.createElement("div"); r.id = "xr-overlay-root"; document.body.appendChild(r); }
 
 
 
 
128
  return r;
129
  }
130
  var overlayRoot = ensureOverlayRoot();
131
 
132
+ // --------- Système de messages : toast bas OU centre agrandi ----------
133
  function getMessageEl() {
134
  var el = overlayRoot.querySelector(".pc-ar-msg");
135
+ if (!el) { el = document.createElement("div"); el.className = "pc-ar-msg"; overlayRoot.appendChild(el); }
 
 
 
 
136
  return el;
137
  }
138
+ function messageToast(txt) {
139
+ var el = getMessageEl(); el.classList.remove("pc-ar-msg--centerBig"); el.textContent = txt;
 
 
140
  }
141
+ function messageCenterBig(txt) {
142
+ var el = getMessageEl(); el.classList.add("pc-ar-msg--centerBig"); el.textContent = txt;
 
 
143
  }
144
 
145
+ // ===================== iOS Quick Look helpers =====================
146
  function buildQuickLookHref(usdzUrl) {
147
  try {
148
  var u = new URL(usdzUrl, window.location.href);
 
155
  return usdzUrl + (usdzUrl.indexOf('#') >= 0 ? '&' : '#') + 'allowsContentScaling=0';
156
  }
157
  }
158
+ // Affiche une **ancre visible** rel="ar" (obligatoire pour ouverture directe)
159
+ function ensureIOSQuickLookLink(usdzUrl) {
160
+ var link = document.getElementById("ar-launch-link");
161
+ if (!link) {
162
+ link = document.createElement("a");
163
+ link.id = "ar-launch-link";
164
+ link.className = "ar-launch-link";
165
+ link.setAttribute("rel", "ar");
166
+ link.textContent = "Lancer l’AR";
167
+ document.body.appendChild(link);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  }
169
+ link.setAttribute("href", buildQuickLookHref(usdzUrl));
170
+ return link;
 
 
 
 
 
 
 
171
  }
172
 
173
+ // ===================== Canvas monté dans un conteneur (Squarespace ok) =====================
174
  function ensureCanvas() {
175
  var scriptEl = getCurrentScript();
176
  var mountSel = scriptEl && scriptEl.getAttribute('data-mount');
177
  var desiredHeight = (scriptEl && scriptEl.getAttribute('data-height')) || '70vh';
178
 
 
179
  var mountEl = null;
180
  if (mountSel) mountEl = document.querySelector(mountSel);
181
+ if (!mountEl) { mountEl = document.createElement('div'); mountEl.id = 'ar-mount-fallback'; document.body.insertBefore(mountEl, document.body.firstChild); }
 
 
 
 
 
182
 
183
+ var ms = mountEl.style;
184
+ if (!ms.position) ms.position = 'relative';
185
+ ms.width = ms.width || '100%';
186
+ ms.minHeight = ms.minHeight || desiredHeight;
187
+ ms.touchAction = ms.touchAction || 'manipulation';
188
+ ms.webkitTapHighlightColor = 'transparent';
 
189
 
 
190
  var canvas = mountEl.querySelector('#application-canvas');
191
+ if (!canvas) { canvas = document.createElement('canvas'); canvas.id = 'application-canvas'; mountEl.appendChild(canvas); }
 
 
 
 
192
 
 
193
  var cs = canvas.style;
194
+ cs.position = 'absolute'; cs.left = '0'; cs.top = '0'; cs.width = '100%'; cs.height = '100%'; cs.display = 'block';
 
 
 
 
 
 
 
 
 
 
195
 
196
+ try { mountEl.scrollIntoView({ behavior: 'instant', block: 'start' }); } catch (_e) {}
197
  return canvas;
198
  }
199
 
200
+ // ---------- Panneau “Rotation” (inchangé) ----------
201
  function ensureSliderUI() {
202
  var p = overlayRoot.querySelector(".ar-ui");
203
  if (p) return p;
 
215
  return p;
216
  }
217
 
218
+ // ===================== Boot : charge config, iOS vs Android/Desktop =====================
219
  (async function () {
220
  var cfgUrl = findConfigUrl();
221
  var cfg = await loadConfigJson(cfgUrl);
222
  var GLB_URL = (cfg && typeof cfg.glb_url === "string" && cfg.glb_url)
223
  ? cfg.glb_url
224
  : "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/AR/tests/danae_no_metallic.glb";
225
+ var USDZ_URL = (cfg && typeof cfg.usdz_url === "string" && cfg.usdz_url) ? cfg.usdz_url : null;
 
 
226
 
227
+ // ---- iOS : Quick Look via lien <a rel="ar"> (ouverture directe) ----
228
  if (isIOS()) {
229
  if (USDZ_URL) {
230
+ ensureIOSQuickLookLink(USDZ_URL);
 
 
231
  messageCenterBig("Modèle chargé. Appuyez sur « Lancer l’AR » pour démarrer.");
232
  } else {
233
  messageToast("iOS détecté, mais aucun 'usdz_url' dans config.json.");
 
235
  return;
236
  }
237
 
238
+ // ---- Android/Desktop : WebXR ----
239
  try {
240
  await loadPlayCanvasRobust({ esmFirst: true, loadTimeoutMs: 15000 });
241
  } catch (e) {
 
246
  initARApp(GLB_URL);
247
  })();
248
 
249
+ // ===================== App WebXR (Android/Desktop) =====================
250
  function initARApp(GLB_URL) {
251
  var pc = window.pc;
252
 
 
253
  var canvas = ensureCanvas();
254
  var sliderPanel = ensureSliderUI();
255
  var rotTrack = sliderPanel.querySelector("#ar-rotY-wrap");
 
259
 
260
  window.focus();
261
 
 
262
  var app = new pc.Application(canvas, {
263
  mouse: new pc.Mouse(canvas),
264
  touch: new pc.TouchDevice(canvas),
 
274
  app.on("destroy", function () { window.removeEventListener("resize", onResize); });
275
  app.start();
276
 
277
+ // Rendu / PBR par défaut
278
  app.scene.gammaCorrection = pc.GAMMA_SRGB;
279
  app.scene.toneMapping = pc.TONEMAP_ACES;
280
  app.scene.exposure = 1;
281
  app.scene.ambientLight = new pc.Color(1, 1, 1);
282
 
283
+ // Caméra + lumière
284
  var camera = new pc.Entity("Camera");
285
  camera.addComponent("camera", { clearColor: new pc.Color(0, 0, 0, 0), farClip: 10000 });
286
  app.root.addChild(camera);
 
290
  mainLight.setLocalEulerAngles(45, 30, 0);
291
  app.root.addChild(mainLight);
292
 
293
+ // Réticule
294
  var reticleMat = new pc.StandardMaterial();
295
  reticleMat.diffuse = new pc.Color(0.2, 0.8, 1.0);
296
  reticleMat.opacity = 0.85;
297
  reticleMat.blendType = pc.BLEND_NORMAL;
298
  reticleMat.update();
 
299
  var reticle = new pc.Entity("Reticle");
300
  reticle.addComponent("render", { type: "torus", material: reticleMat });
301
  reticle.setLocalScale(0.12, 0.005, 0.12);
302
  reticle.enabled = false;
303
  app.root.addChild(reticle);
304
 
305
+ // Modèle
306
  var modelRoot = new pc.Entity("ModelRoot");
307
  modelRoot.enabled = false;
308
  app.root.addChild(modelRoot);
309
 
310
+ var modelLoaded = false, placedOnce = false;
 
311
 
312
+ // Ombre de contact (blob)
313
  var blobShadowEntity = null;
314
+ var BLOB_SIZE = 0.4, BLOB_OFFSET_Y = 0.005;
 
315
 
316
  function makeBlobTexture(appRef, size) {
317
  var s = size || 256;
318
+ var cvs = document.createElement('canvas'); cvs.width = cvs.height = s;
 
319
  var ctx = cvs.getContext('2d');
320
  var r = s * 0.45;
321
+ var grd = ctx.createRadialGradient(s/2, s/2, r*0.2, s/2, s/2, r);
322
+ grd.addColorStop(0, 'rgba(0,0,0,0.5)'); grd.addColorStop(1, 'rgba(0,0,0,0)');
323
+ ctx.fillStyle = grd; ctx.fillRect(0,0,s,s);
 
 
 
324
  var tex = new pc.Texture(appRef.graphicsDevice, {
325
+ width: s, height: s, format: pc.PIXELFORMAT_R8_G8_B8_A8,
326
+ mipmaps: true, magFilter: pc.FILTER_LINEAR, minFilter: pc.FILTER_LINEAR_MIPMAP_LINEAR
 
 
 
 
327
  });
328
+ tex.setSource(cvs); return tex;
 
329
  }
 
330
  function createBlobShadowAt(pos, rot) {
331
  var tex = makeBlobTexture(app, 256);
 
332
  var blobMat = new pc.StandardMaterial();
333
+ blobMat.diffuse = new pc.Color(0,0,0);
334
  blobMat.opacity = 1.0;
335
  blobMat.opacityMap = tex;
336
  blobMat.opacityMapChannel = 'a';
 
339
  blobMat.depthWrite = false;
340
  blobMat.alphaTest = 0;
341
  blobMat.update();
 
342
  var e = new pc.Entity("BlobShadow");
343
  e.addComponent("render", { type: "plane", castShadows: false, receiveShadows: false });
344
  e.render.material = blobMat;
 
345
  e.setPosition(pos.x, pos.y + BLOB_OFFSET_Y, pos.z);
346
  e.setRotation(rot);
347
  e.setLocalScale(BLOB_SIZE, 1, BLOB_SIZE);
 
348
  app.root.addChild(e);
349
  return e;
350
  }
351
 
352
+ // Euler de base & rotation slider
353
+ var baseEulerX = 0, baseEulerZ = 0;
 
 
 
354
  var rotationYDeg = 0;
355
  function clamp360(d) { return Math.max(0, Math.min(360, d)); }
 
356
  function updateKnobFromY(yDeg) {
357
  var t = 1 - (yDeg / 360);
358
  rotThumb.style.top = String(t * 100) + "%";
 
365
  modelRoot.setEulerAngles(baseEulerX, y, baseEulerZ);
366
  updateKnobFromY(y);
367
  }
 
368
  function updateBlobPositionUnder(pos, rotLikePlane) {
369
  if (!blobShadowEntity) return;
370
  blobShadowEntity.setPosition(pos.x, pos.y + BLOB_OFFSET_Y, pos.z);
371
  if (rotLikePlane) blobShadowEntity.setRotation(rotLikePlane);
372
  }
373
 
374
+ // Chargement GLB
375
  app.assets.loadFromUrl(GLB_URL, "container", function (err, asset) {
376
  if (err) { console.error(err); messageToast("Échec du chargement du modèle GLB."); return; }
377
  var instance = asset.resource.instantiateRenderEntity({ castShadows: true, receiveShadows: false });
378
  modelRoot.addChild(instance);
379
+ modelRoot.setLocalScale(1,1,1);
380
 
 
381
  var renders = instance.findComponents('render');
382
  for (var ri = 0; ri < renders.length; ri++) {
383
+ var r = renders[ri]; r.castShadows = true;
 
384
  for (var mi = 0; mi < r.meshInstances.length; mi++) {
385
+ var mat = r.meshInstances[mi].material; if (!mat) continue;
386
+ if (mat.diffuse && (mat.diffuse.r !== 1 || mat.diffuse.g !== 1 || mat.diffuse.b !== 1)) mat.diffuse.set(1,1,1);
 
 
 
387
  if (typeof mat.useSkybox !== "undefined") mat.useSkybox = true;
388
  mat.update();
389
  }
 
393
  baseEulerX = initE.x; baseEulerZ = initE.z;
394
 
395
  modelLoaded = true;
 
396
  messageCenterBig("Modèle chargé. Touchez l’écran pour démarrer l’AR.");
397
  });
398
 
399
  if (!app.xr.supported) { messageToast("WebXR n’est pas supporté sur cet appareil."); return; }
400
 
401
+ // Slider robuste (pointer events capture)
402
+ var isUIInteracting = false, isTrackDragging = false, activePointerId = null;
 
 
 
403
  function insideTrack(target) { return rotTrack.contains(target); }
404
  function degFromPointer(e) {
405
  var rect = rotTrack.getBoundingClientRect();
406
  var y = (e.clientY != null) ? e.clientY : ((e.touches && e.touches[0] && e.touches[0].clientY) || 0);
407
+ var ratio = (y - rect.top) / rect.height; var t = Math.max(0, Math.min(1, ratio));
408
+ return (1 - t) * 360;
 
409
  }
 
410
  function onPointerDownCapture(e) {
411
  if (!insideTrack(e.target)) return;
412
+ isUIInteracting = true; isTrackDragging = true;
 
413
  activePointerId = (e.pointerId != null) ? e.pointerId : 1;
414
+ if (rotTrack.setPointerCapture) { try { rotTrack.setPointerCapture(activePointerId); } catch (_er) {} }
 
 
415
  applyRotationY(degFromPointer(e));
416
+ e.preventDefault(); e.stopPropagation();
 
417
  }
418
  function onPointerMoveCapture(e) {
419
  if (!isTrackDragging || ((e.pointerId != null ? e.pointerId : 1) !== activePointerId)) return;
420
  applyRotationY(degFromPointer(e));
421
+ e.preventDefault(); e.stopPropagation();
 
422
  }
423
  function endDrag(e) {
424
  if (!isTrackDragging || ((e.pointerId != null ? e.pointerId : 1) !== activePointerId)) return;
425
+ isTrackDragging = false; isUIInteracting = false;
426
+ if (rotTrack.releasePointerCapture) { try { rotTrack.releasePointerCapture(activePointerId); } catch (_er) {} }
427
+ activePointerId = null; e.preventDefault(); e.stopPropagation();
 
 
 
 
 
428
  }
 
429
  document.addEventListener("pointerdown", onPointerDownCapture, { capture: true, passive: false });
430
  document.addEventListener("pointermove", onPointerMoveCapture, { capture: true, passive: false });
431
  document.addEventListener("pointerup", endDrag, { capture: true, passive: false });
432
  document.addEventListener("pointercancel", endDrag, { capture: true, passive: false });
433
 
434
+ // Démarrage AR (tap écran)
435
  function activateAR() {
436
  if (!app.xr.isAvailable(pc.XRTYPE_AR)) { messageToast("AR immersive indisponible sur cet appareil."); return; }
437
  if (!app.xr.domOverlay) app.xr.domOverlay = {};
 
440
  requiredFeatures: ["hit-test", "dom-overlay"],
441
  domOverlay: { root: app.xr.domOverlay.root },
442
  callback: function (err) {
443
+ if (err) { console.error("Échec du démarrage AR :", err); messageToast("Échec du démarrage AR : " + (err.message || err)); }
 
 
 
444
  }
445
  });
446
  }
 
447
  app.mouse.on("mousedown", function () { if (!app.xr.active && !isUIInteracting) activateAR(); });
448
  if (app.touch) {
449
  app.touch.on("touchend", function (evt) {
450
  if (!app.xr.active && !isUIInteracting) activateAR();
451
+ evt.event.preventDefault(); evt.event.stopPropagation();
 
452
  });
453
  }
454
  app.keyboard.on("keydown", function (evt) { if (evt.key === pc.KEY_ESCAPE && app.xr.active) app.xr.end(); });
455
 
456
  // Hit-test HORIZONTAL uniquement
457
+ var TMP_IN = new pc.Vec3(0,1,0), TMP_OUT = new pc.Vec3();
 
458
  function isHorizontalUpFacing(rot, minDot) {
459
  var md = (typeof minDot === "number") ? minDot : 0.75;
460
  rot.transformVector(TMP_IN, TMP_OUT);
461
+ return TMP_OUT.y >= md;
462
  }
 
 
463
  app.xr.hitTest.on("available", function () {
464
  app.xr.hitTest.start({
465
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
 
467
  if (err) { messageToast("Le AR hit test n’a pas pu démarrer."); return; }
468
  hitSource.on("result", function (pos, rot) {
469
  if (!isHorizontalUpFacing(rot)) return;
470
+ reticle.enabled = true; reticle.setPosition(pos); reticle.setRotation(rot);
 
 
 
471
 
472
  if (modelLoaded && !placedOnce) {
473
+ modelRoot.enabled = true; modelRoot.setPosition(pos);
 
 
 
474
  blobShadowEntity = createBlobShadowAt(pos, rot);
475
 
476
+ var e = new pc.Vec3(); rot.getEulerAngles(e);
477
+ var y0 = ((e.y % 360) + 360) % 360; applyRotationY(y0);
 
 
478
 
479
+ placedOnce = true; rotRangeInput.disabled = false;
 
 
 
480
  messageToast("Objet placé. Glissez pour déplacer, tournez-le avec le slider →");
481
  }
482
  });
 
484
  });
485
  });
486
 
487
+ // Drag XR — ignoré si UI active
488
  var isModelDragging = false;
489
  app.xr.input.on("add", function (inputSource) {
490
  inputSource.on("selectstart", function () {
491
  if (isUIInteracting) return;
492
  if (!placedOnce || !modelLoaded) return;
 
493
  inputSource.hitTestStart({
494
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
495
  callback: function (err, transientSource) {
496
  if (err) return;
497
  isModelDragging = true;
 
498
  transientSource.on("result", function (pos, rot) {
499
  if (!isModelDragging) return;
500
  if (!isHorizontalUpFacing(rot)) return;
501
+ modelRoot.setPosition(pos); updateBlobPositionUnder(pos, rot);
 
502
  });
 
503
  transientSource.once("remove", function () { isModelDragging = false; });
504
  }
505
  });
 
507
  inputSource.on("selectend", function () { isModelDragging = false; });
508
  });
509
 
510
+ // Desktop : rotation souris
511
+ var isRotateMode = false, lastMouseX = 0;
 
512
  var ROTATE_SENSITIVITY = 0.25;
513
  app.mouse.on("mousedown", function (e) {
514
  if (!app.xr.active || !placedOnce || isUIInteracting) return;
515
+ if (e.button === 0 && !e.shiftKey) { isModelDragging = true; }
516
+ else if (e.button === 2 || (e.button === 0 && e.shiftKey)) { isRotateMode = true; lastMouseX = e.x; }
 
 
 
 
517
  });
518
  app.mouse.on("mousemove", function (e) {
519
  if (!app.xr.active || !placedOnce || isUIInteracting) return;
520
  if (isModelDragging) {
521
  if (reticle.enabled) {
522
+ var p = reticle.getPosition(); modelRoot.setPosition(p); updateBlobPositionUnder(p, null);
 
 
523
  }
524
  } else if (isRotateMode && modelRoot.enabled) {
525
+ var dx = e.x - lastMouseX; lastMouseX = e.x;
 
526
  applyRotationY(rotationYDeg + dx * ROTATE_SENSITIVITY);
527
  }
528
  });
529
  app.mouse.on("mouseup", function () { isModelDragging = false; isRotateMode = false; });
530
  window.addEventListener("contextmenu", function (e) { e.preventDefault(); });
531
 
532
+ // Slider (input range, accessibilité)
533
  rotRangeInput.disabled = true;
534
  rotRangeInput.addEventListener("input", function (e) {
535
  if (!modelRoot.enabled) return;
536
+ var v = parseFloat(e.target.value || "0"); applyRotationY(v);
 
537
  }, { passive: true });
538
 
539
+ // Événements AR
540
+ app.xr.on("start", function () { messageToast("Session AR démarrée. Visez le sol pour détecter un plan…"); reticle.enabled = true; });
 
 
 
541
  app.xr.on("end", function () {
542
  messageToast("Session AR terminée.");
543
+ reticle.enabled = false; isModelDragging = false; isRotateMode = false; rotRangeInput.disabled = true;
 
 
 
544
  });
545
  app.xr.on("available:" + pc.XRTYPE_AR, function (a) {
546
  if (!a) messageToast("AR immersive indisponible.");
 
553
  else messageToast("Chargement du modèle…");
554
  }
555
  })();
556
+ </script>