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

Update viewer_ar.js

Browse files
Files changed (1) hide show
  1. viewer_ar.js +162 -313
viewer_ar.js CHANGED
@@ -1,325 +1,187 @@
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
  (() => {
9
- // =========================
10
- // 1) Paramètres principaux
11
- // =========================
12
  const GLB_URL = "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/AR/tests/fraisier.glb";
13
 
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
-
27
- const tryESM = async () => {
28
- for (const url of PC_URLS.esm) {
29
- try {
30
- const mod = await Promise.race([import(/* @vite-ignore */ url), timeout(loadTimeoutMs)]);
31
- const namespace = mod?.pc || mod?.default || mod;
32
- if (namespace?.Application) { if (!window.pc) window.pc = namespace; return window.pc; }
33
- } catch (_) {}
34
- }
35
- throw new Error("ESM failed");
36
- };
37
-
38
- const tryUMD = async () => {
39
- for (const url of PC_URLS.umd) {
40
- try {
41
- await Promise.race([
42
- new Promise((res, rej) => {
43
- const s = document.createElement("script");
44
- s.src = url; s.async = true;
45
- s.onload = () => res(); s.onerror = () => rej(new Error("script error"));
46
- document.head.appendChild(s);
47
- }),
48
- timeout(loadTimeoutMs)
49
- ]);
50
- if (window.pc && typeof window.pc.Application === "function") return window.pc;
51
- } catch (_) {}
52
- }
53
- throw new Error("UMD failed");
54
- };
55
-
56
- if (esmFirst) { try { return await tryESM(); } catch (_) {} return await tryUMD(); }
57
- else { try { return await tryUMD(); } catch (_) {} return await tryESM(); }
58
  }
59
 
60
- // =========================
61
- // 3) UI & utilitaires (DOM Overlay)
62
- // =========================
63
  const css = `
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);
71
- backdrop-filter: blur(4px); user-select: none; pointer-events: none;
72
- }
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;
128
- document.head.appendChild(styleTag);
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();
136
 
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
 
150
- function ensureSliderUI() {
151
- let panel = overlayRoot.querySelector(".ar-ui");
152
- if (panel) return panel;
153
- panel = document.createElement("div");
154
- panel.className = "ar-ui";
155
- panel.innerHTML = `
156
  <div class="label">Rotation</div>
157
  <div class="rotY-wrap" id="ar-rotY-wrap">
158
  <div class="rotY-rail"></div>
159
- <input class="rotY" id="ar-rotY" type="range" min="0" max="360" step="1" value="0" />
160
  </div>
161
- <div class="val" id="ar-rotY-val">0°</div>
162
- `;
163
- overlayRoot.appendChild(panel);
164
- return panel;
165
  }
166
 
167
- // =========================
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
-
176
- // =========================
177
- // 5) App AR PlayCanvas
178
- // =========================
179
- function initARApp() {
180
- const pc = window.pc;
181
- const canvas = ensureCanvas();
182
- const uiPanel = ensureSliderUI();
183
-
184
- const rotYInput = uiPanel.querySelector("#ar-rotY");
185
- const rotYVal = uiPanel.querySelector("#ar-rotY-val");
186
- const rotWrap = uiPanel.querySelector("#ar-rotY-wrap");
187
 
188
- window.focus();
 
 
 
 
 
 
189
 
190
- const app = new pc.Application(canvas, {
191
- mouse: new pc.Mouse(canvas),
192
- touch: new pc.TouchDevice(canvas),
193
- keyboard: new pc.Keyboard(window),
194
- graphicsDeviceOptions: { alpha: true }
195
- });
196
 
 
197
  app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
198
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
199
- app.graphicsDevice.maxPixelRatio = window.devicePixelRatio || 1;
200
-
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 });
247
  modelRoot.addChild(instance);
248
- modelRoot.setLocalScale(0.2, 0.2, 0.2);
249
- modelLoaded = true;
 
 
 
 
 
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) => {
302
- if (!app.xr.active && !uiInteracting) activateAR();
303
- evt.event.preventDefault(); evt.event.stopPropagation();
304
- });
305
- }
306
 
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
  }
325
  });
@@ -327,54 +189,41 @@
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) => {
342
- if (err) return;
343
- isDragging = true;
344
- transientSource.on("result", (pos) => { if (isDragging) modelRoot.setPosition(pos); });
345
- transientSource.once("remove", () => { isDragging = false; });
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.");
377
- else if (!app.xr.hitTest.supported) message("AR Hit Test non supporté.");
378
  else message("Chargement du modèle…");
379
  }
380
  })();
 
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), sans inversion 0→360
 
5
  */
6
 
7
  (() => {
 
 
 
8
  const GLB_URL = "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/AR/tests/fraisier.glb";
9
 
 
 
 
10
  const PC_VERSION = "2.11.7";
11
  const PC_URLS = {
12
  esm: [`https://cdn.jsdelivr.net/npm/playcanvas@${PC_VERSION}/build/playcanvas.min.mjs`],
13
  umd: [`https://cdn.jsdelivr.net/npm/playcanvas@${PC_VERSION}/build/playcanvas.min.js`]
14
  };
15
 
16
+ function timeout(ms){return new Promise((_,rej)=>setTimeout(()=>rej(new Error("timeout")),ms));}
17
+ async function loadPlayCanvasRobust({esmFirst=true,loadTimeoutMs=15000}={}){
18
+ if(window.pc?.Application) return window.pc;
19
+ const tryESM=async()=>{for(const url of PC_URLS.esm){try{const mod=await Promise.race([import(url),timeout(loadTimeoutMs)]);const ns=mod?.pc||mod?.default||mod; if(ns?.Application){return (window.pc=ns);}}catch{}} throw new Error("ESM failed");};
20
+ const tryUMD=async()=>{for(const url of PC_URLS.umd){try{await Promise.race([new Promise((res,rej)=>{const s=document.createElement("script");s.src=url;s.async=true;s.onload=()=>res();s.onerror=()=>rej(new Error("script error"));document.head.appendChild(s);}),timeout(loadTimeoutMs)]); if(window.pc?.Application) return window.pc;}catch{}} throw new Error("UMD failed");};
21
+ try{ return esmFirst ? await tryESM() : await tryUMD(); }catch{ return esmFirst ? await tryUMD() : await tryESM(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  }
23
 
 
 
 
24
  const css = `
25
+ .pc-ar-msg{position:fixed;left:50%;transform:translateX(-50%);bottom:16px;z-index:2;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}
26
+ #xr-overlay-root{position:fixed;inset:0;z-index:9999;pointer-events:none}
27
+ .ar-ui{position:absolute;right:12px;top:50%;transform:translateY(-50%);background:rgba(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}
28
+ .ar-ui .label{font-size:12px;text-align:center;opacity:.95}
29
+ .rotY-wrap{position:relative;width:48px;height:200px;display:flex;align-items:center;justify-content:center;touch-action:none}
30
+ .rotY-rail{position:absolute;width:4px;height:86%;background:rgba(255,255,255,.35);border-radius:2px;left:50%;transform:translateX(-50%)}
31
+ .ar-ui input[type="range"].rotY{-webkit-appearance:none;position:relative;width:160px;height:22px;transform:rotate(-90deg);transform-origin:center;outline:none;background:transparent;touch-action:none;pointer-events:none;display:block}
32
+ .ar-ui input[type="range"].rotY::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:20px;height:20px;border-radius:50%;background:#fff;border:none;box-shadow:0 2px 8px rgba(0,0,0,.35)}
33
+ .ar-ui input[type="range"].rotY::-webkit-slider-runnable-track{height:4px;background:rgba(255,255,255,.6);border-radius:2px}
34
+ .ar-ui .val{font-size:12px;opacity:.95}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  `;
36
+ const styleTag=document.createElement("style");styleTag.textContent=css;document.head.appendChild(styleTag);
 
 
 
 
 
 
 
 
 
37
 
38
+ function ensureOverlayRoot(){let r=document.getElementById("xr-overlay-root"); if(!r){r=document.createElement("div");r.id="xr-overlay-root";document.body.appendChild(r);} return r;}
39
+ const overlayRoot=ensureOverlayRoot();
 
 
 
 
40
 
41
+ function message(msg){let el=overlayRoot.querySelector(".pc-ar-msg"); if(!el){el=document.createElement("div");el.className="pc-ar-msg";overlayRoot.appendChild(el);} el.textContent=msg;}
42
+
43
+ function ensureCanvas(){let c=document.getElementById("application-canvas"); if(!c){c=document.createElement("canvas");c.id="application-canvas";c.style.width="100%";c.style.height="100%";document.body.appendChild(c);} return c;}
 
 
44
 
45
+ function ensureSliderUI(){
46
+ let p=overlayRoot.querySelector(".ar-ui"); if(p) return p;
47
+ p=document.createElement("div"); p.className="ar-ui";
48
+ p.innerHTML=`
 
 
49
  <div class="label">Rotation</div>
50
  <div class="rotY-wrap" id="ar-rotY-wrap">
51
  <div class="rotY-rail"></div>
52
+ <input class="rotY" id="ar-rotY" type="range" min="0" max="360" step="1" value="0"/>
53
  </div>
54
+ <div class="val" id="ar-rotY-val">0°</div>`;
55
+ overlayRoot.appendChild(p);
56
+ return p;
 
57
  }
58
 
59
+ (async()=>{try{await loadPlayCanvasRobust({esmFirst:true,loadTimeoutMs:15000});}catch(e){console.error("Chargement PlayCanvas échoué ->",e);message("Impossible de charger PlayCanvas (réseau/CDN). Réessaie plus tard.");return;} initARApp();})();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
+ function initARApp(){
62
+ const pc=window.pc;
63
+ const canvas=ensureCanvas();
64
+ const ui=ensureSliderUI();
65
+ const rotYInput=ui.querySelector("#ar-rotY");
66
+ const rotYVal=ui.querySelector("#ar-rotY-val");
67
+ const rotWrap=ui.querySelector("#ar-rotY-wrap");
68
 
69
+ window.focus();
 
 
 
 
 
70
 
71
+ const app=new pc.Application(canvas,{mouse:new pc.Mouse(canvas),touch:new pc.TouchDevice(canvas),keyboard:new pc.Keyboard(window),graphicsDeviceOptions:{alpha:true}});
72
  app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
73
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
74
+ app.graphicsDevice.maxPixelRatio=window.devicePixelRatio||1;
75
+ const onResize=()=>app.resizeCanvas(); window.addEventListener("resize",onResize); app.on("destroy",()=>window.removeEventListener("resize",onResize));
 
 
 
76
  app.start();
77
 
78
+ // Camera & lumière
79
+ const camera=new pc.Entity("Camera");
80
+ camera.addComponent("camera",{clearColor:new pc.Color(0,0,0,0),farClip:10000}); app.root.addChild(camera);
81
+ const light=new pc.Entity("Light");
82
+ light.addComponent("light",{type:"spot",range:30,intensity:1.1,castShadows:false}); light.setLocalPosition(0,10,0); app.root.addChild(light);
83
+
84
+ // Réticule
85
+ const reticleMat=new pc.StandardMaterial(); reticleMat.diffuse=new pc.Color(0.2,0.8,1.0); reticleMat.opacity=0.85; reticleMat.blendType=pc.BLEND_NORMAL; reticleMat.update();
86
+ const reticle=new pc.Entity("Reticle"); reticle.addComponent("render",{type:"torus",material:reticleMat}); reticle.setLocalScale(0.12,0.005,0.12); reticle.enabled=false; app.root.addChild(reticle);
87
+
88
+ // Modèle
89
+ const modelRoot=new pc.Entity("ModelRoot"); modelRoot.enabled=false; app.root.addChild(modelRoot);
90
+ let modelLoaded=false, placedOnce=false;
91
+
92
+ // Base X/Z figées pour éviter les inversions d’Euler
93
+ let baseEulerX = 0, baseEulerZ = 0;
94
+
95
+ // Rotation Y (clamp 0..360, pas de wrap)
96
+ let rotationYDeg=0;
97
+ const clamp360 = d => Math.max(0, Math.min(360, d));
98
+ const to0to360 = d => { let a = d % 360; if (a < 0) a += 360; return a; };
99
+
100
+ // ✅ n'utilise plus getEulerAngles() en continu
101
+ function applyRotationY(deg){
102
+ const y = clamp360(deg);
103
+ rotationYDeg = y;
104
+ modelRoot.setEulerAngles(baseEulerX, y, baseEulerZ);
105
+ rotYInput.value = String(Math.round(y));
106
+ rotYVal.textContent = `${Math.round(y)}°`;
107
+ }
 
 
 
 
 
 
108
 
109
  // Chargement GLB
110
+ app.assets.loadFromUrl(GLB_URL,"container",(err,asset)=>{
111
+ if(err){console.error(err);message("Échec du chargement du modèle GLB.");return;}
112
+ const instance=asset.resource.instantiateRenderEntity({castShadows:false,receiveShadows:false});
113
  modelRoot.addChild(instance);
114
+ modelRoot.setLocalScale(0.2,0.2,0.2);
115
+
116
+ // mémorise la base X/Z une fois (au repos, avant AR)
117
+ const initE = modelRoot.getEulerAngles();
118
+ baseEulerX = initE.x; baseEulerZ = initE.z;
119
+
120
+ modelLoaded=true;
121
  message("Modèle chargé. Touchez l’écran pour démarrer l’AR.");
122
  });
123
 
124
+ if(!app.xr.supported){message("WebXR n’est pas supporté sur cet appareil.");return;}
 
 
 
 
 
125
 
126
+ // Anti-fuite événements UI + grande hitbox
127
+ let uiInteracting=false, draggingWrap=false;
128
+ const stop=e=>{e.stopPropagation();e.preventDefault();};
129
+ ["pointerdown","pointermove","pointerup","pointercancel","touchstart","touchmove","touchend","touchcancel","mousedown","mousemove","mouseup","wheel","click"].forEach(evt=>{
130
+ ui.addEventListener(evt,stop,{passive:false});
131
+ rotYInput.addEventListener(evt,stop,{passive:false});
132
  });
133
+ function valueFromWrapEvent(ev){
134
+ const rect=rotWrap.getBoundingClientRect();
135
+ const clientY=(ev.touches&&ev.touches[0])?ev.touches[0].clientY:ev.clientY;
136
+ const ratio=1-((clientY-rect.top)/rect.height); // haut=1, bas=0
137
+ const t=Math.max(0,Math.min(1,ratio));
138
+ return t*360; // 0..360 (linéaire, sans inversion)
 
 
139
  }
140
+ function beginWrapDrag(e){uiInteracting=true;draggingWrap=true;stop(e);rotWrap.setPointerCapture?.(e.pointerId||1);applyRotationY(valueFromWrapEvent(e));}
141
+ function moveWrapDrag(e){if(!draggingWrap)return;stop(e);applyRotationY(valueFromWrapEvent(e));}
142
+ function endWrapDrag(e){if(!draggingWrap)return;stop(e);draggingWrap=false;uiInteracting=false;try{rotWrap.releasePointerCapture?.(e.pointerId||1);}catch{}}
143
+
144
+ rotWrap.addEventListener("pointerdown",beginWrapDrag,{passive:false});
145
+ rotWrap.addEventListener("pointermove",moveWrapDrag,{passive:false});
146
+ rotWrap.addEventListener("pointerup",endWrapDrag,{passive:false});
147
+ rotWrap.addEventListener("pointercancel",endWrapDrag,{passive:false});
148
+ rotWrap.addEventListener("touchstart",beginWrapDrag,{passive:false});
149
+ rotWrap.addEventListener("touchmove",moveWrapDrag,{passive:false});
150
+ rotWrap.addEventListener("touchend",endWrapDrag,{passive:false});
151
+ rotWrap.addEventListener("touchcancel",endWrapDrag,{passive:false});
152
+
153
+ // Démarrage AR
154
+ const activateAR=()=>{
155
+ if(!app.xr.isAvailable(pc.XRTYPE_AR)){message("AR immersive indisponible sur cet appareil.");return;}
156
+ app.xr.domOverlay.root=document.getElementById("xr-overlay-root");
157
+ camera.camera.startXr(pc.XRTYPE_AR,pc.XRSPACE_LOCALFLOOR,{
158
+ requiredFeatures:["hit-test","dom-overlay"],
159
+ domOverlay:{root:app.xr.domOverlay.root},
160
+ callback:(err)=>{if(err){console.error("Échec du démarrage AR :",err);message(`Échec du démarrage AR : ${err.message||err}`);}}
161
  });
162
  };
163
+ app.mouse.on("mousedown",()=>{if(!app.xr.active && !uiInteracting) activateAR();});
164
+ if(app.touch){app.touch.on("touchend",(evt)=>{if(!app.xr.active && !uiInteracting) activateAR(); evt.event.preventDefault();evt.event.stopPropagation();});}
165
+ app.keyboard.on("keydown",(evt)=>{if(evt.key===pc.KEY_ESCAPE && app.xr.active) app.xr.end();});
166
 
167
+ // Hit Test & 1er placement
168
+ app.xr.hitTest.on("available",()=>{
169
+ app.xr.hitTest.start({
170
+ entityTypes:[pc.XRTRACKABLE_POINT,pc.XRTRACKABLE_PLANE],
171
+ callback:(err,hitSource)=>{
172
+ if(err){message("Le AR hit test n’a pas pu démarrer.");return;}
173
+ hitSource.on("result",(pos,rot)=>{
174
+ reticle.enabled=true; reticle.setPosition(pos); reticle.setRotation(rot);
175
 
176
+ if(modelLoaded && !placedOnce){
177
+ modelRoot.enabled=true; modelRoot.setPosition(pos);
178
 
179
+ // Yaw initiale normalisée 0..360 (pas de wrap négatif)
180
+ const e = new pc.Vec3(); rot.getEulerAngles(e);
181
+ const y0 = clamp360(to0to360(e.y));
182
+ applyRotationY(y0);
183
+
184
+ placedOnce=true; rotYInput.disabled=false;
 
 
 
 
 
 
 
185
  message("Objet placé. Glissez pour déplacer, tournez-le avec le slider →");
186
  }
187
  });
 
189
  });
190
  });
191
 
192
+ // Déplacement XR (ignore si UI)
193
+ let isDragging=false;
194
+ app.xr.input.on("add",(inputSource)=>{
195
+ inputSource.on("selectstart",()=>{
196
+ if(uiInteracting) return;
197
+ if(!placedOnce || !modelLoaded) return;
 
 
 
198
  inputSource.hitTestStart({
199
+ entityTypes:[pc.XRTRACKABLE_POINT,pc.XRTRACKABLE_PLANE],
200
+ callback:(err,ts)=>{ if(err) return; isDragging=true;
201
+ ts.on("result",(pos)=>{ if(isDragging) modelRoot.setPosition(pos); });
202
+ ts.once("remove",()=>{isDragging=false;});
 
 
203
  }
204
  });
205
  });
206
+ inputSource.on("selectend",()=>{isDragging=false;});
207
  });
208
 
209
+ // (Desktop) rotation à la souris conservée
210
+ let rotateMode=false,lastMouseX=0; const ROTATE_SENSITIVITY=0.25;
211
+ app.mouse.on("mousedown",(e)=>{if(!app.xr.active||!placedOnce||uiInteracting)return; if(e.button===0&&!e.shiftKey)isDragging=true; else if(e.button===2||(e.button===0&&e.shiftKey)){rotateMode=true;lastMouseX=e.x;}});
212
+ app.mouse.on("mousemove",(e)=>{if(!app.xr.active||!placedOnce||uiInteracting)return; if(isDragging){if(reticle.enabled) modelRoot.setPosition(reticle.getPosition());}else if(rotateMode&&modelRoot.enabled){const dx=e.x-lastMouseX;lastMouseX=e.x;applyRotationY(rotationYDeg+dx*ROTATE_SENSITIVITY);}});
213
+ app.mouse.on("mouseup",()=>{isDragging=false;rotateMode=false;});
214
+ window.addEventListener("contextmenu",(e)=>e.preventDefault());
 
 
 
 
 
 
 
215
 
216
+ // Slider (au cas via clavier)
217
+ rotYInput.disabled=true;
218
+ rotYInput.addEventListener("input",(e)=>{ if(!modelRoot.enabled) return; applyRotationY(parseFloat(e.target.value||"0")); },{passive:true});
219
 
220
+ // Événements AR
221
+ app.xr.on("start",()=>{message("Session AR démarrée. Visez le sol pour détecter un plan…"); reticle.enabled=true;});
222
+ app.xr.on("end",()=>{message("Session AR terminée."); reticle.enabled=false; isDragging=false; rotateMode=false; rotYInput.disabled=true;});
223
+ app.xr.on(`available:${pc.XRTYPE_AR}`,(avail)=>{ if(!avail) 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…"); });
224
 
225
+ if(!app.xr.isAvailable(pc.XRTYPE_AR)) message("AR immersive indisponible.");
226
+ else if(!app.xr.hitTest.supported) message("AR Hit Test non supporté.");
 
227
  else message("Chargement du modèle…");
228
  }
229
  })();