MikaFil commited on
Commit
c3b4352
·
verified ·
1 Parent(s): a017d0c

Update viewer_ar.js

Browse files
Files changed (1) hide show
  1. viewer_ar.js +83 -163
viewer_ar.js CHANGED
@@ -1,8 +1,8 @@
1
  /* script_ar.js — AR PlayCanvas + GLB HuggingFace
2
  - Version PC verrouillée
3
  - WebXR AR Hit Test, placement auto, drag
4
- - Rotation Y via SLIDER (DOM Overlay) avec large zone tactile
5
- - Aucune dépendance externe autre que PlayCanvas et le GLB
6
  */
7
 
8
  (() => {
@@ -14,16 +14,13 @@
14
  // =========================
15
  // 2) Chargeur PlayCanvas ROBUSTE (version verrouillée)
16
  // =========================
17
- const PC_VERSION = "2.11.7"; // même version pour ESM et UMD
18
  const PC_URLS = {
19
  esm: [`https://cdn.jsdelivr.net/npm/playcanvas@${PC_VERSION}/build/playcanvas.min.mjs`],
20
  umd: [`https://cdn.jsdelivr.net/npm/playcanvas@${PC_VERSION}/build/playcanvas.min.js`]
21
  };
22
 
23
- function timeout(ms) {
24
- return new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms));
25
- }
26
-
27
  async function loadPlayCanvasRobust({ esmFirst = true, loadTimeoutMs = 15000 } = {}) {
28
  if (window.pc && typeof window.pc.Application === "function") return window.pc;
29
 
@@ -67,7 +64,7 @@
67
  .pc-ar-msg {
68
  position: fixed; left: 50%; transform: translateX(-50%);
69
  bottom: 16px; z-index: 2; padding: 10px 14px;
70
- background: rgba(0,0,0,.65); color: #fff; border-radius: 8px;
71
  font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
72
  font-size: 14px; line-height: 1.3; text-align: center;
73
  max-width: min(90vw, 640px); box-shadow: 0 6px 20px rgba(0,0,0,.25);
@@ -76,48 +73,55 @@
76
  #xr-overlay-root {
77
  position: fixed; inset: 0; z-index: 9999; pointer-events: none;
78
  }
 
79
  .ar-ui {
80
  position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
81
- background: rgba(0,0,0,0.55); color: #fff; padding: 12px 10px; border-radius: 12px;
 
82
  font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
83
- pointer-events: auto; width: 72px; display: flex; flex-direction: column; align-items: center; gap: 10px;
84
- box-shadow: 0 6px 18px rgba(0,0,0,.25); backdrop-filter: blur(4px);
85
  touch-action: none;
86
  }
87
- .ar-ui .label { font-size: 12px; text-align: center; opacity: 0.9; }
88
 
89
- /* GRAND WRAPPER TACTILE (hitbox) */
90
  .rotY-wrap {
91
  position: relative;
92
- width: 60px; /* largeur tactile confortable */
93
- height: 260px; /* hauteur tactile confortable */
94
  display: flex; align-items: center; justify-content: center;
95
  touch-action: none;
96
  }
97
- /* Rail visuel (optionnel) */
98
  .rotY-rail {
99
- position: absolute; width: 6px; height: 80%;
100
- background: rgba(255,255,255,.35); border-radius: 3px;
 
101
  }
102
 
103
- /* Slider réel, tourné, mais on ne dépend PAS de son hitbox */
104
  .ar-ui input[type="range"].rotY {
105
  -webkit-appearance: none;
106
  position: relative;
107
- width: 220px; height: 32px;
108
  transform: rotate(-90deg);
 
109
  outline: none; background: transparent; touch-action: none;
110
- pointer-events: none; /* <- on lit sa valeur mais on ne compte pas sur sa hitbox */
 
111
  }
112
  .ar-ui input[type="range"].rotY::-webkit-slider-thumb {
113
- -webkit-appearance: none; appearance: none; width: 26px; height: 26px; border-radius: 50%;
114
- background: #fff; border: none; box-shadow: 0 2px 8px rgba(0,0,0,.35);
 
 
115
  }
116
  .ar-ui input[type="range"].rotY::-webkit-slider-runnable-track {
117
- height: 6px; background: rgba(255,255,255,.6); border-radius: 4px;
118
  }
119
 
120
- .ar-ui .val { font-size: 12px; opacity: 0.9; }
121
  `;
122
  const styleTag = document.createElement("style");
123
  styleTag.textContent = css;
@@ -125,11 +129,7 @@
125
 
126
  function ensureOverlayRoot() {
127
  let root = document.getElementById("xr-overlay-root");
128
- if (!root) {
129
- root = document.createElement("div");
130
- root.id = "xr-overlay-root";
131
- document.body.appendChild(root);
132
- }
133
  return root;
134
  }
135
  const overlayRoot = ensureOverlayRoot();
@@ -137,23 +137,13 @@
137
  // Messages dans l'overlay
138
  function message(msg) {
139
  let el = overlayRoot.querySelector(".pc-ar-msg");
140
- if (!el) {
141
- el = document.createElement("div");
142
- el.className = "pc-ar-msg";
143
- overlayRoot.appendChild(el);
144
- }
145
  el.textContent = msg;
146
  }
147
 
148
  function ensureCanvas() {
149
  let canvas = document.getElementById("application-canvas");
150
- if (!canvas) {
151
- canvas = document.createElement("canvas");
152
- canvas.id = "application-canvas";
153
- canvas.style.width = "100%";
154
- canvas.style.height = "100%";
155
- document.body.appendChild(canvas);
156
- }
157
  return canvas;
158
  }
159
 
@@ -178,13 +168,8 @@
178
  // 4) Lancement
179
  // =========================
180
  (async () => {
181
- try {
182
- await loadPlayCanvasRobust({ esmFirst: true, loadTimeoutMs: 15000 });
183
- } catch (e) {
184
- console.error("Chargement PlayCanvas échoué ->", e);
185
- message("Impossible de charger PlayCanvas (réseau/CDN). Réessaie plus tard.");
186
- return;
187
- }
188
  initARApp();
189
  })();
190
 
@@ -216,60 +201,46 @@
216
  const onResize = () => app.resizeCanvas();
217
  window.addEventListener("resize", onResize);
218
  app.on("destroy", () => window.removeEventListener("resize", onResize));
219
-
220
  app.start();
221
 
222
  // --- Camera & light ---
223
  const camera = new pc.Entity("Camera");
224
- camera.addComponent("camera", {
225
- clearColor: new pc.Color(0, 0, 0, 0),
226
- farClip: 10000
227
- });
228
  app.root.addChild(camera);
229
 
230
  const light = new pc.Entity("Light");
231
- light.addComponent("light", {
232
- type: "spot",
233
- range: 30,
234
- intensity: 1.1,
235
- castShadows: false
236
- });
237
  light.setLocalPosition(0, 10, 0);
238
  app.root.addChild(light);
239
 
240
- // --- Reticle (anneau) ---
241
  const reticleMat = new pc.StandardMaterial();
242
  reticleMat.diffuse = new pc.Color(0.2, 0.8, 1.0);
243
- reticleMat.opacity = 0.85;
244
- reticleMat.blendType = pc.BLEND_NORMAL;
245
- reticleMat.update();
246
-
247
  const reticle = new pc.Entity("Reticle");
248
  reticle.addComponent("render", { type: "torus", material: reticleMat });
249
  reticle.setLocalScale(0.12, 0.005, 0.12);
250
- reticle.enabled = false;
251
- app.root.addChild(reticle);
252
 
253
- // --- Conteneur modèle GLB ---
254
  const modelRoot = new pc.Entity("ModelRoot");
255
- modelRoot.enabled = false;
256
- app.root.addChild(modelRoot);
257
 
258
- let modelLoaded = false;
259
- let placedOnce = false;
260
 
261
- // Rotation Y (0..360)
262
  let rotationYDeg = 0;
263
- const norm360 = (deg) => { let d = deg % 360; return d < 0 ? d + 360 : d; };
264
  const applyRotationY = (deg) => {
265
- rotationYDeg = norm360(deg);
 
266
  const eul = modelRoot.getEulerAngles();
267
- modelRoot.setEulerAngles(eul.x, rotationYDeg, eul.z);
268
- rotYInput.value = String(Math.round(rotationYDeg));
269
- rotYVal.textContent = `${Math.round(rotationYDeg)}°`;
270
  };
271
 
272
- // --- Chargement GLB ---
273
  app.assets.loadFromUrl(GLB_URL, "container", (err, asset) => {
274
  if (err) { console.error(err); message("Échec du chargement du modèle GLB."); return; }
275
  const instance = asset.resource.instantiateRenderEntity({ castShadows: false, receiveShadows: false });
@@ -279,70 +250,52 @@
279
  message("Modèle chargé. Touchez l’écran pour démarrer l’AR.");
280
  });
281
 
282
- // --- Vérifs WebXR ---
283
  if (!app.xr.supported) { message("WebXR n’est pas supporté sur cet appareil."); return; }
284
 
285
  // ====== Anti-fuite d'événements UI + grande hitbox ======
286
- let uiInteracting = false;
287
- let draggingWrap = false;
288
-
289
  const stop = (e) => { e.stopPropagation(); e.preventDefault(); };
290
 
291
- // 1) Empêcher toute propagation depuis le panneau
292
  ["pointerdown","pointermove","pointerup","pointercancel","touchstart","touchmove","touchend","touchcancel","mousedown","mousemove","mouseup","wheel","click"].forEach(evt => {
293
  uiPanel.addEventListener(evt, stop, { passive: false });
294
  rotYInput.addEventListener(evt, stop, { passive: false });
295
  });
296
 
297
- // 2) Drag custom sur le GRAND WRAPPER vertical
298
  function valueFromWrapEvent(ev) {
299
  const rect = rotWrap.getBoundingClientRect();
300
  const clientY = (ev.touches && ev.touches[0]) ? ev.touches[0].clientY : ev.clientY;
301
- const ratio = 1 - ((clientY - rect.top) / rect.height); // 1 en haut, 0 en bas
302
- const clamped = Math.max(0, Math.min(1, ratio));
303
- return clamped * 360;
304
- }
305
-
306
- function beginWrapDrag(e) {
307
- uiInteracting = true; draggingWrap = true; stop(e);
308
- rotWrap.setPointerCapture?.(e.pointerId || 1);
309
- applyRotationY(valueFromWrapEvent(e));
310
- }
311
- function moveWrapDrag(e) {
312
- if (!draggingWrap) return; stop(e);
313
- applyRotationY(valueFromWrapEvent(e));
314
- }
315
- function endWrapDrag(e) {
316
- if (!draggingWrap) return; stop(e);
317
- draggingWrap = false; uiInteracting = false;
318
- try { rotWrap.releasePointerCapture?.(e.pointerId || 1); } catch(_) {}
319
  }
 
 
 
320
 
321
  rotWrap.addEventListener("pointerdown", beginWrapDrag, { passive: false });
322
  rotWrap.addEventListener("pointermove", moveWrapDrag, { passive: false });
323
  rotWrap.addEventListener("pointerup", endWrapDrag, { passive: false });
324
  rotWrap.addEventListener("pointercancel", endWrapDrag, { passive: false });
 
 
 
 
325
 
326
- rotWrap.addEventListener("touchstart", beginWrapDrag, { passive: false });
327
- rotWrap.addEventListener("touchmove", moveWrapDrag, { passive: false });
328
- rotWrap.addEventListener("touchend", endWrapDrag, { passive: false });
329
- rotWrap.addEventListener("touchcancel",endWrapDrag, { passive: false });
330
-
331
- // --- Démarrage AR (CameraComponent.startXr + DOM Overlay) ---
332
  const activateAR = () => {
333
  if (!app.xr.isAvailable(pc.XRTYPE_AR)) { message("AR immersive indisponible sur cet appareil."); return; }
334
  app.xr.domOverlay.root = document.getElementById("xr-overlay-root");
335
-
336
  camera.camera.startXr(pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, {
337
  requiredFeatures: ["hit-test", "dom-overlay"],
338
  domOverlay: { root: app.xr.domOverlay.root },
339
- callback: (err) => {
340
- if (err) { console.error("Échec du démarrage AR :", err); message(`Échec du démarrage AR : ${err.message || err}`); }
341
- }
342
  });
343
  };
344
 
345
- // Premier tap / clic → démarrer AR (ignorer si UI en cours)
346
  app.mouse.on("mousedown", () => { if (!app.xr.active && !uiInteracting) activateAR(); });
347
  if (app.touch) {
348
  app.touch.on("touchend", (evt) => {
@@ -354,23 +307,18 @@
354
  // ESC → quitter
355
  app.keyboard.on("keydown", (evt) => { if (evt.key === pc.KEY_ESCAPE && app.xr.active) app.xr.end(); });
356
 
357
- // --- Hit Test global (réglage réticule + 1er placement) ---
358
  app.xr.hitTest.on("available", () => {
359
  app.xr.hitTest.start({
360
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
361
  callback: (err, hitSource) => {
362
  if (err) { message("Le AR hit test n’a pas pu démarrer."); return; }
363
  hitSource.on("result", (pos, rot) => {
364
- reticle.enabled = true;
365
- reticle.setPosition(pos);
366
- reticle.setRotation(rot);
367
-
368
  if (modelLoaded && !placedOnce) {
369
- modelRoot.enabled = true;
370
- modelRoot.setPosition(pos);
371
  const euler = new pc.Vec3(); rot.getEulerAngles(euler);
372
- rotationYDeg = (euler.y % 360 + 360) % 360;
373
- applyRotationY(rotationYDeg);
374
  placedOnce = true; rotYInput.disabled = false;
375
  message("Objet placé. Glissez pour déplacer, tournez-le avec le slider →");
376
  }
@@ -379,18 +327,15 @@
379
  });
380
  });
381
 
382
- // --- Interactions (déplacement & rotation souris) ---
383
- let isDragging = false;
384
- let rotateMode = false;
385
- let lastMouseX = 0;
386
  const ROTATE_SENSITIVITY = 0.25;
387
 
388
- // Déplacement (input XR : maintien/drag) — IGNORE si UI en cours
389
  app.xr.input.on("add", (inputSource) => {
390
  inputSource.on("selectstart", () => {
391
  if (uiInteracting) return;
392
  if (!placedOnce || !modelLoaded) return;
393
-
394
  inputSource.hitTestStart({
395
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
396
  callback: (err, transientSource) => {
@@ -401,56 +346,31 @@
401
  }
402
  });
403
  });
404
-
405
  inputSource.on("selectend", () => { isDragging = false; });
406
  });
407
 
408
- // Souris : déplacement (gauche) & rotation (droit ou Shift+gauche) — IGNORE si UI en cours
409
  app.mouse.on("mousedown", (e) => {
410
  if (!app.xr.active || !placedOnce || uiInteracting) return;
411
  if (e.button === 0 && !e.shiftKey) isDragging = true;
412
  else if (e.button === 2 || (e.button === 0 && e.shiftKey)) { rotateMode = true; lastMouseX = e.x; }
413
  });
414
-
415
  app.mouse.on("mousemove", (e) => {
416
  if (!app.xr.active || !placedOnce || uiInteracting) return;
417
  if (isDragging) { if (reticle.enabled) modelRoot.setPosition(reticle.getPosition()); }
418
- else if (rotateMode && modelRoot.enabled) {
419
- const dx = e.x - lastMouseX; lastMouseX = e.x;
420
- applyRotationY(rotationYDeg + dx * ROTATE_SENSITIVITY);
421
- }
422
  });
423
-
424
  app.mouse.on("mouseup", () => { isDragging = false; rotateMode = false; });
425
  window.addEventListener("contextmenu", (e) => e.preventDefault());
426
 
427
- // --- Slider rotation (DOM overlay) ---
428
- rotYInput.disabled = true; // activé au 1er placement
429
- rotYInput.addEventListener("input", (e) => {
430
- if (!modelRoot.enabled) return;
431
- applyRotationY(parseFloat(e.target.value || "0"));
432
- }, { passive: true });
433
-
434
- // --- Événements AR globaux ---
435
- app.xr.on("start", () => {
436
- console.log("[XR] start — domOverlay.supported:", app.xr.domOverlay.supported, "available:", app.xr.domOverlay.available);
437
- message("Session AR démarrée. Visez le sol pour détecter un plan…");
438
- reticle.enabled = true;
439
- });
440
 
441
- app.xr.on("end", () => {
442
- message("Session AR terminée.");
443
- reticle.enabled = false;
444
- isDragging = false;
445
- rotateMode = false;
446
- rotYInput.disabled = true;
447
- });
448
-
449
- app.xr.on(`available:${pc.XRTYPE_AR}`, (available) => {
450
- if (!available) message("AR immersive indisponible.");
451
- else if (!app.xr.hitTest.supported) message("AR Hit Test non supporté.");
452
- else message(modelLoaded ? "Touchez l’écran pour démarrer l’AR." : "Chargement du modèle…");
453
- });
454
 
455
  // Message initial
456
  if (!app.xr.isAvailable(pc.XRTYPE_AR)) message("AR immersive indisponible.");
 
1
  /* script_ar.js — AR PlayCanvas + GLB HuggingFace
2
  - Version PC verrouillée
3
  - WebXR AR Hit Test, placement auto, drag
4
+ - Rotation Y via SLIDER (DOM Overlay) hitbox large, thumb centré
5
+ - Pas de wrap à 360° (clamp 0..360)
6
  */
7
 
8
  (() => {
 
14
  // =========================
15
  // 2) Chargeur PlayCanvas ROBUSTE (version verrouillée)
16
  // =========================
17
+ const PC_VERSION = "2.11.7";
18
  const PC_URLS = {
19
  esm: [`https://cdn.jsdelivr.net/npm/playcanvas@${PC_VERSION}/build/playcanvas.min.mjs`],
20
  umd: [`https://cdn.jsdelivr.net/npm/playcanvas@${PC_VERSION}/build/playcanvas.min.js`]
21
  };
22
 
23
+ function timeout(ms) { return new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms)); }
 
 
 
24
  async function loadPlayCanvasRobust({ esmFirst = true, loadTimeoutMs = 15000 } = {}) {
25
  if (window.pc && typeof window.pc.Application === "function") return window.pc;
26
 
 
64
  .pc-ar-msg {
65
  position: fixed; left: 50%; transform: translateX(-50%);
66
  bottom: 16px; z-index: 2; padding: 10px 14px;
67
+ background: rgba(0,0,0,.65); color: #fff; border-radius: 12px;
68
  font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
69
  font-size: 14px; line-height: 1.3; text-align: center;
70
  max-width: min(90vw, 640px); box-shadow: 0 6px 20px rgba(0,0,0,.25);
 
73
  #xr-overlay-root {
74
  position: fixed; inset: 0; z-index: 9999; pointer-events: none;
75
  }
76
+ /* ----- Panneau + slider (plus compact) ----- */
77
  .ar-ui {
78
  position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
79
+ background: rgba(0,0,0,0.55); color: #fff; padding: 12px 10px;
80
+ border-radius: 16px; width: 56px;
81
  font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
82
+ pointer-events: auto; display: flex; flex-direction: column; align-items: center; gap: 8px;
83
+ box-shadow: 0 6px 18px rgba(0,0,0,.25); backdrop-filter: blur(6px);
84
  touch-action: none;
85
  }
86
+ .ar-ui .label { font-size: 12px; text-align: center; opacity: 0.95; }
87
 
88
+ /* Grande zone tactile verticale */
89
  .rotY-wrap {
90
  position: relative;
91
+ width: 48px; /* plus étroit */
92
+ height: 200px; /* plus court */
93
  display: flex; align-items: center; justify-content: center;
94
  touch-action: none;
95
  }
96
+ /* Rail visuel centré */
97
  .rotY-rail {
98
+ position: absolute; width: 4px; height: 86%;
99
+ background: rgba(255,255,255,.35); border-radius: 2px;
100
+ left: 50%; transform: translateX(-50%);
101
  }
102
 
103
+ /* Slider réel, visuel seulement (on pilote via rotY-wrap) */
104
  .ar-ui input[type="range"].rotY {
105
  -webkit-appearance: none;
106
  position: relative;
107
+ width: 160px; height: 22px; /* plus petit */
108
  transform: rotate(-90deg);
109
+ transform-origin: center; /* centre le thumb après rotation */
110
  outline: none; background: transparent; touch-action: none;
111
+ pointer-events: none; /* on ne s'appuie pas sur sa hitbox */
112
+ display: block;
113
  }
114
  .ar-ui input[type="range"].rotY::-webkit-slider-thumb {
115
+ -webkit-appearance: none; appearance: none;
116
+ width: 20px; height: 20px; /* bouton rond plus petit */
117
+ border-radius: 50%; background: #fff; border: none;
118
+ box-shadow: 0 2px 8px rgba(0,0,0,.35);
119
  }
120
  .ar-ui input[type="range"].rotY::-webkit-slider-runnable-track {
121
+ height: 4px; background: rgba(255,255,255,.6); border-radius: 2px;
122
  }
123
 
124
+ .ar-ui .val { font-size: 12px; opacity: 0.95; }
125
  `;
126
  const styleTag = document.createElement("style");
127
  styleTag.textContent = css;
 
129
 
130
  function ensureOverlayRoot() {
131
  let root = document.getElementById("xr-overlay-root");
132
+ if (!root) { root = document.createElement("div"); root.id = "xr-overlay-root"; document.body.appendChild(root); }
 
 
 
 
133
  return root;
134
  }
135
  const overlayRoot = ensureOverlayRoot();
 
137
  // Messages dans l'overlay
138
  function message(msg) {
139
  let el = overlayRoot.querySelector(".pc-ar-msg");
140
+ if (!el) { el = document.createElement("div"); el.className = "pc-ar-msg"; overlayRoot.appendChild(el); }
 
 
 
 
141
  el.textContent = msg;
142
  }
143
 
144
  function ensureCanvas() {
145
  let canvas = document.getElementById("application-canvas");
146
+ if (!canvas) { canvas = document.createElement("canvas"); canvas.id = "application-canvas"; canvas.style.width = "100%"; canvas.style.height = "100%"; document.body.appendChild(canvas); }
 
 
 
 
 
 
147
  return canvas;
148
  }
149
 
 
168
  // 4) Lancement
169
  // =========================
170
  (async () => {
171
+ try { await loadPlayCanvasRobust({ esmFirst: true, loadTimeoutMs: 15000 }); }
172
+ catch (e) { console.error("Chargement PlayCanvas échoué ->", e); message("Impossible de charger PlayCanvas (réseau/CDN). Réessaie plus tard."); return; }
 
 
 
 
 
173
  initARApp();
174
  })();
175
 
 
201
  const onResize = () => app.resizeCanvas();
202
  window.addEventListener("resize", onResize);
203
  app.on("destroy", () => window.removeEventListener("resize", onResize));
 
204
  app.start();
205
 
206
  // --- Camera & light ---
207
  const camera = new pc.Entity("Camera");
208
+ camera.addComponent("camera", { clearColor: new pc.Color(0, 0, 0, 0), farClip: 10000 });
 
 
 
209
  app.root.addChild(camera);
210
 
211
  const light = new pc.Entity("Light");
212
+ light.addComponent("light", { type: "spot", range: 30, intensity: 1.1, castShadows: false });
 
 
 
 
 
213
  light.setLocalPosition(0, 10, 0);
214
  app.root.addChild(light);
215
 
216
+ // --- Reticle ---
217
  const reticleMat = new pc.StandardMaterial();
218
  reticleMat.diffuse = new pc.Color(0.2, 0.8, 1.0);
219
+ reticleMat.opacity = 0.85; reticleMat.blendType = pc.BLEND_NORMAL; reticleMat.update();
 
 
 
220
  const reticle = new pc.Entity("Reticle");
221
  reticle.addComponent("render", { type: "torus", material: reticleMat });
222
  reticle.setLocalScale(0.12, 0.005, 0.12);
223
+ reticle.enabled = false; app.root.addChild(reticle);
 
224
 
225
+ // --- Modèle GLB ---
226
  const modelRoot = new pc.Entity("ModelRoot");
227
+ modelRoot.enabled = false; app.root.addChild(modelRoot);
 
228
 
229
+ let modelLoaded = false, placedOnce = false;
 
230
 
231
+ // Rotation Y (clamp 0..360 — pas de wrap)
232
  let rotationYDeg = 0;
233
+ const clamp360 = (deg) => Math.max(0, Math.min(360, deg));
234
  const applyRotationY = (deg) => {
235
+ const clamped = clamp360(deg);
236
+ rotationYDeg = clamped;
237
  const eul = modelRoot.getEulerAngles();
238
+ modelRoot.setEulerAngles(eul.x, clamped, eul.z);
239
+ rotYInput.value = String(Math.round(clamped));
240
+ rotYVal.textContent = `${Math.round(clamped)}°`;
241
  };
242
 
243
+ // Chargement GLB
244
  app.assets.loadFromUrl(GLB_URL, "container", (err, asset) => {
245
  if (err) { console.error(err); message("Échec du chargement du modèle GLB."); return; }
246
  const instance = asset.resource.instantiateRenderEntity({ castShadows: false, receiveShadows: false });
 
250
  message("Modèle chargé. Touchez l’écran pour démarrer l’AR.");
251
  });
252
 
253
+ // Vérifs WebXR
254
  if (!app.xr.supported) { message("WebXR n’est pas supporté sur cet appareil."); return; }
255
 
256
  // ====== Anti-fuite d'événements UI + grande hitbox ======
257
+ let uiInteracting = false, draggingWrap = false;
 
 
258
  const stop = (e) => { e.stopPropagation(); e.preventDefault(); };
259
 
260
+ // Bloque toute propagation depuis le panneau/slider
261
  ["pointerdown","pointermove","pointerup","pointercancel","touchstart","touchmove","touchend","touchcancel","mousedown","mousemove","mouseup","wheel","click"].forEach(evt => {
262
  uiPanel.addEventListener(evt, stop, { passive: false });
263
  rotYInput.addEventListener(evt, stop, { passive: false });
264
  });
265
 
266
+ // Drag custom sur le wrapper (vertical)
267
  function valueFromWrapEvent(ev) {
268
  const rect = rotWrap.getBoundingClientRect();
269
  const clientY = (ev.touches && ev.touches[0]) ? ev.touches[0].clientY : ev.clientY;
270
+ const ratio = 1 - ((clientY - rect.top) / rect.height); // 1 en haut, 0 en bas
271
+ const clamped01 = Math.max(0, Math.min(1, ratio));
272
+ return clamped01 * 360; // 0 .. 360 (sans wrap)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  }
274
+ function beginWrapDrag(e) { uiInteracting = true; draggingWrap = true; stop(e); rotWrap.setPointerCapture?.(e.pointerId || 1); applyRotationY(valueFromWrapEvent(e)); }
275
+ function moveWrapDrag(e) { if (!draggingWrap) return; stop(e); applyRotationY(valueFromWrapEvent(e)); }
276
+ function endWrapDrag(e) { if (!draggingWrap) return; stop(e); draggingWrap = false; uiInteracting = false; try { rotWrap.releasePointerCapture?.(e.pointerId || 1); } catch(_) {} }
277
 
278
  rotWrap.addEventListener("pointerdown", beginWrapDrag, { passive: false });
279
  rotWrap.addEventListener("pointermove", moveWrapDrag, { passive: false });
280
  rotWrap.addEventListener("pointerup", endWrapDrag, { passive: false });
281
  rotWrap.addEventListener("pointercancel", endWrapDrag, { passive: false });
282
+ rotWrap.addEventListener("touchstart", beginWrapDrag, { passive: false });
283
+ rotWrap.addEventListener("touchmove", moveWrapDrag, { passive: false });
284
+ rotWrap.addEventListener("touchend", endWrapDrag, { passive: false });
285
+ rotWrap.addEventListener("touchcancel",endWrapDrag, { passive: false });
286
 
287
+ // Démarrage AR (CameraComponent.startXr + DOM Overlay)
 
 
 
 
 
288
  const activateAR = () => {
289
  if (!app.xr.isAvailable(pc.XRTYPE_AR)) { message("AR immersive indisponible sur cet appareil."); return; }
290
  app.xr.domOverlay.root = document.getElementById("xr-overlay-root");
 
291
  camera.camera.startXr(pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, {
292
  requiredFeatures: ["hit-test", "dom-overlay"],
293
  domOverlay: { root: app.xr.domOverlay.root },
294
+ callback: (err) => { if (err) { console.error("Échec du démarrage AR :", err); message(`Échec du démarrage AR : ${err.message || err}`); } }
 
 
295
  });
296
  };
297
 
298
+ // Tap / clic → start AR (ignore si sur l'UI)
299
  app.mouse.on("mousedown", () => { if (!app.xr.active && !uiInteracting) activateAR(); });
300
  if (app.touch) {
301
  app.touch.on("touchend", (evt) => {
 
307
  // ESC → quitter
308
  app.keyboard.on("keydown", (evt) => { if (evt.key === pc.KEY_ESCAPE && app.xr.active) app.xr.end(); });
309
 
310
+ // Hit Test (réticule + 1er placement)
311
  app.xr.hitTest.on("available", () => {
312
  app.xr.hitTest.start({
313
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
314
  callback: (err, hitSource) => {
315
  if (err) { message("Le AR hit test n’a pas pu démarrer."); return; }
316
  hitSource.on("result", (pos, rot) => {
317
+ reticle.enabled = true; reticle.setPosition(pos); reticle.setRotation(rot);
 
 
 
318
  if (modelLoaded && !placedOnce) {
319
+ modelRoot.enabled = true; modelRoot.setPosition(pos);
 
320
  const euler = new pc.Vec3(); rot.getEulerAngles(euler);
321
+ applyRotationY(Math.max(0, Math.min(360, euler.y))); // clamp initial
 
322
  placedOnce = true; rotYInput.disabled = false;
323
  message("Objet placé. Glissez pour déplacer, tournez-le avec le slider →");
324
  }
 
327
  });
328
  });
329
 
330
+ // Interactions (déplacement & rotation souris)
331
+ let isDragging = false, rotateMode = false, lastMouseX = 0;
 
 
332
  const ROTATE_SENSITIVITY = 0.25;
333
 
334
+ // Déplacement (XR input) — ignoré si UI en cours
335
  app.xr.input.on("add", (inputSource) => {
336
  inputSource.on("selectstart", () => {
337
  if (uiInteracting) return;
338
  if (!placedOnce || !modelLoaded) return;
 
339
  inputSource.hitTestStart({
340
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
341
  callback: (err, transientSource) => {
 
346
  }
347
  });
348
  });
 
349
  inputSource.on("selectend", () => { isDragging = false; });
350
  });
351
 
352
+ // Souris (desktop)
353
  app.mouse.on("mousedown", (e) => {
354
  if (!app.xr.active || !placedOnce || uiInteracting) return;
355
  if (e.button === 0 && !e.shiftKey) isDragging = true;
356
  else if (e.button === 2 || (e.button === 0 && e.shiftKey)) { rotateMode = true; lastMouseX = e.x; }
357
  });
 
358
  app.mouse.on("mousemove", (e) => {
359
  if (!app.xr.active || !placedOnce || uiInteracting) return;
360
  if (isDragging) { if (reticle.enabled) modelRoot.setPosition(reticle.getPosition()); }
361
+ else if (rotateMode && modelRoot.enabled) { const dx = e.x - lastMouseX; lastMouseX = e.x; applyRotationY(rotationYDeg + dx * ROTATE_SENSITIVITY); }
 
 
 
362
  });
 
363
  app.mouse.on("mouseup", () => { isDragging = false; rotateMode = false; });
364
  window.addEventListener("contextmenu", (e) => e.preventDefault());
365
 
366
+ // Slider (événement input si jamais utilisé via clavier)
367
+ rotYInput.disabled = true;
368
+ rotYInput.addEventListener("input", (e) => { if (!modelRoot.enabled) return; applyRotationY(parseFloat(e.target.value || "0")); }, { passive: true });
 
 
 
 
 
 
 
 
 
 
369
 
370
+ // Événements AR globaux
371
+ app.xr.on("start", () => { console.log("[XR] start — domOverlay.supported:", app.xr.domOverlay.supported, "available:", app.xr.domOverlay.available); message("Session AR démarrée. Visez le sol pour détecter un plan…"); reticle.enabled = true; });
372
+ app.xr.on("end", () => { message("Session AR terminée."); reticle.enabled = false; isDragging = false; rotateMode = false; rotYInput.disabled = true; });
373
+ app.xr.on(`available:${pc.XRTYPE_AR}`, (available) => { if (!available) message("AR immersive indisponible."); else if (!app.xr.hitTest.supported) message("AR Hit Test non supporté."); else message(modelLoaded ? "Touchez l’écran pour démarrer l’AR." : "Chargement du modèle…"); });
 
 
 
 
 
 
 
 
 
374
 
375
  // Message initial
376
  if (!app.xr.isAvailable(pc.XRTYPE_AR)) message("AR immersive indisponible.");