MikaFil commited on
Commit
8672e41
·
verified ·
1 Parent(s): 35735de

Update viewer_ar_ios.js

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