MikaFil commited on
Commit
041fcae
·
verified ·
1 Parent(s): 22e060d

Update viewer_ar.js

Browse files
Files changed (1) hide show
  1. viewer_ar.js +339 -205
viewer_ar.js CHANGED
@@ -6,86 +6,170 @@
6
  - Blob Shadow (ombre de contact) sous l’objet
7
  */
8
 
9
- (() => {
10
  const GLB_URL = "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/AR/tests/danae_no_metallic.glb";
11
 
12
  // ===== PlayCanvas version fixée =====
13
  const PC_VERSION = "2.11.7";
14
  const PC_URLS = {
15
- esm: [`https://cdn.jsdelivr.net/npm/playcanvas@${PC_VERSION}/build/playcanvas.min.mjs`],
16
- umd: [`https://cdn.jsdelivr.net/npm/playcanvas@${PC_VERSION}/build/playcanvas.min.js`]
17
  };
18
 
19
- function timeout(ms){return new Promise((_,rej)=>setTimeout(()=>rej(new Error("timeout")),ms));}
20
- async function loadPlayCanvasRobust({esmFirst=true,loadTimeoutMs=15000}={}){
21
- if (window.pc?.Application) return window.pc;
22
- const tryESM = async () => { for (const url of PC_URLS.esm){ try{ const mod=await Promise.race([import(/* @vite-ignore */ url), timeout(loadTimeoutMs)]); const ns=mod?.pc||mod?.default||mod; if(ns?.Application){ return (window.pc=ns);} }catch{}} throw new Error("ESM failed"); };
23
- 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"); };
24
- try{ return esmFirst ? await tryESM() : await tryUMD(); }catch{ return esmFirst ? await tryUMD() : await tryESM(); }
25
  }
26
 
27
- // ===== UI / Overlay =====
28
- const css = `
29
- .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}
30
- #xr-overlay-root{position:fixed;inset:0;z-index:9999;pointer-events:none}
31
-
32
- .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}
33
-
34
- .ar-ui .label{font-size:12px;text-align:center;opacity:.95}
35
-
36
- /* Grande zone tactile (box grise) */
37
- .rotY-wrap{position:relative;width:48px;height:200px;display:flex;align-items:center;justify-content:center;touch-action:none;overflow:visible;pointer-events:auto}
38
-
39
- /* Rail PLEIN (de haut en bas), centré */
40
- .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}
41
-
42
- /* Knob custom bien centré */
43
- .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}
 
 
 
 
 
 
44
 
45
- /* Input range caché (pour accessibilité/valeur) */
46
- .ar-ui input[type="range"].rotY{position:absolute;opacity:0;pointer-events:none;width:0;height:0}
47
- .ar-ui .val{font-size:12px;opacity:.95}
48
- `;
49
- const styleTag = document.createElement("style"); styleTag.textContent = css; document.head.appendChild(styleTag);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
- 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; }
52
- const overlayRoot = ensureOverlayRoot();
 
 
 
 
 
 
53
 
54
- 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; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
- 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; }
 
 
 
 
 
 
 
 
 
 
57
 
58
- function ensureSliderUI(){
59
- let p=overlayRoot.querySelector(".ar-ui"); if(p) return p;
60
- p=document.createElement("div"); p.className="ar-ui";
61
- p.innerHTML=`
62
- <div class="label">Rotation</div>
63
- <div class="rotY-wrap" id="ar-rotY-wrap">
64
- <div class="rotY-rail"></div>
65
- <div class="rotY-knob" id="ar-rotY-knob"></div>
66
- <input class="rotY" id="ar-rotY" type="range" min="0" max="360" step="1" value="0"/>
67
- </div>
68
- <div class="val" id="ar-rotY-val">0°</div>`;
 
 
69
  overlayRoot.appendChild(p);
70
  return p;
71
  }
72
 
73
  // ===== Boot =====
74
- (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(); })();
 
 
 
 
 
 
 
 
 
75
 
76
  // ===== App =====
77
- function initARApp(){
78
- const pc = window.pc;
79
- const canvas = ensureCanvas();
80
- const ui = ensureSliderUI();
81
- const rotWrap = ui.querySelector("#ar-rotY-wrap");
82
- const rotKnob = ui.querySelector("#ar-rotY-knob");
83
- const rotYInput = ui.querySelector("#ar-rotY");
84
- const rotYVal = ui.querySelector("#ar-rotY-val");
85
 
86
  window.focus();
87
 
88
- const app = new pc.Application(canvas, {
89
  mouse: new pc.Mouse(canvas),
90
  touch: new pc.TouchDevice(canvas),
91
  keyboard: new pc.Keyboard(window),
@@ -94,72 +178,89 @@
94
  app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
95
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
96
  app.graphicsDevice.maxPixelRatio = window.devicePixelRatio || 1;
97
- const onResize = () => app.resizeCanvas(); window.addEventListener("resize", onResize); app.on("destroy",()=>window.removeEventListener("resize",onResize));
 
 
98
  app.start();
99
 
100
  // ===== Rendu / PBR defaults =====
101
  app.scene.gammaCorrection = pc.GAMMA_SRGB;
102
- app.scene.toneMapping = pc.TONEMAP_ACES;
103
- app.scene.exposure = 1; // ajuste 0.9–1.8 si besoin
104
- app.scene.ambientLight = new pc.Color(1, 1, 1);
105
 
106
- // Camera + lumière (directionnelle par défaut)
107
- const camera = new pc.Entity("Camera");
108
- camera.addComponent("camera", { clearColor: new pc.Color(0,0,0,0), farClip: 10000 });
109
  app.root.addChild(camera);
110
 
111
- const light = new pc.Entity("Light");
112
- light.addComponent("light", { type: "directional", intensity: 1.0, castShadows: true, color: new pc.Color(1,1,1) });
113
  light.setLocalEulerAngles(45, 30, 0);
114
  app.root.addChild(light);
115
 
116
  // Réticule
117
- 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();
118
- 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);
 
 
 
 
 
 
 
 
 
119
 
120
  // Modèle
121
- const modelRoot = new pc.Entity("ModelRoot"); modelRoot.enabled = false; app.root.addChild(modelRoot);
122
- let modelLoaded=false, placedOnce=false;
 
 
123
 
124
  // ===== Blob Shadow =====
125
- let blob = null; // entité plane de l’ombre
126
- const BLOB_SIZE = 0.7; // diamètre approx. de l’ombre (m)
127
- const BLOB_OFFSET_Y = 0.005; // petit décalage au-dessus du sol pour éviter le z-fighting
128
 
129
- function makeBlobTexture(app, size = 256) {
130
- const cvs = document.createElement('canvas');
 
131
  cvs.width = cvs.height = size;
132
- const ctx = cvs.getContext('2d');
133
- const r = size * 0.45;
134
- const grd = ctx.createRadialGradient(size/2, size/2, r*0.2, size/2, size/2, r);
135
- grd.addColorStop(0, 'rgba(0,0,0,0.35)'); // centre sombre
136
- grd.addColorStop(1, 'rgba(0,0,0,0.0)'); // bords transparents
137
  ctx.fillStyle = grd;
138
  ctx.fillRect(0, 0, size, size);
139
 
140
- const tex = new pc.Texture(app.graphicsDevice, {
141
- width: size, height: size, format: pc.PIXELFORMAT_R8_G8_B8_A8,
142
- mipmaps: true, magFilter: pc.FILTER_LINEAR, minFilter: pc.FILTER_LINEAR_MIPMAP_LINEAR
 
 
 
 
143
  });
144
  tex.setSource(cvs);
145
  return tex;
146
  }
147
 
148
  function createBlobShadowAt(pos, rot) {
149
- const tex = makeBlobTexture(app, 256);
150
- const blobMat = new pc.StandardMaterial();
151
- blobMat.diffuse.set(0,0,0);
152
- blobMat.opacity = 1.0; // la transparence vient de la texture
153
  blobMat.blendType = pc.BLEND_PREMULTIPLIED;
154
- blobMat.depthWrite = false; // pour ne pas masquer l'objet
155
  blobMat.diffuseMap = tex;
156
  blobMat.update();
157
 
158
- const e = new pc.Entity("BlobShadow");
159
  e.addComponent("render", { type: "plane", castShadows: false, receiveShadows: false });
160
  e.render.material = blobMat;
161
 
162
- // place légèrement au-dessus du sol, orienté comme le plan détecté (horizontal)
163
  e.setPosition(pos.x, pos.y + BLOB_OFFSET_Y, pos.z);
164
  e.setRotation(rot);
165
  e.setLocalScale(BLOB_SIZE, 1, BLOB_SIZE);
@@ -168,155 +269,170 @@
168
  return e;
169
  }
170
 
171
- // Euler de base (évite inversions)
172
- let baseEulerX=0, baseEulerZ=0;
173
 
174
- // Rotation Y (clamp 0..360) — 360 en haut, 0 en bas
175
- let rotationYDeg = 0;
176
- const clamp360 = d => Math.max(0, Math.min(360, d));
177
 
178
- function updateKnobFromY(yDeg){
179
- const t = 1 - (yDeg / 360); // 360 -> top(0%), 0 -> bottom(100%)
180
- rotKnob.style.top = `${t*100}%`;
181
  rotYInput.value = String(Math.round(yDeg));
182
- rotYVal.textContent = `${Math.round(yDeg)}°`;
183
  }
184
- function applyRotationY(deg){
185
- const y = clamp360(deg);
186
  rotationYDeg = y;
187
  modelRoot.setEulerAngles(baseEulerX, y, baseEulerZ);
188
  updateKnobFromY(y);
189
  }
190
 
191
- // Helper : met à jour aussi l’ombre quand on déplace l’objet
192
- function updateBlobPositionUnder(pos, rotLikePlane = null) {
193
  if (!blob) return;
194
- // on suit la position XY du modèle, et on s'aligne sur la rotation du plan si fournie
195
  blob.setPosition(pos.x, pos.y + BLOB_OFFSET_Y, pos.z);
196
- if (rotLikePlane) {
197
- blob.setRotation(rotLikePlane);
198
- }
199
  }
200
 
201
- // Chargement GLB + "fix matériaux"
202
- app.assets.loadFromUrl(GLB_URL, "container", (err, asset) => {
203
- if (err){ console.error(err); message("Échec du chargement du modèle GLB."); return; }
204
- const instance = asset.resource.instantiateRenderEntity({ castShadows:true, receiveShadows:false });
205
  modelRoot.addChild(instance);
206
- modelRoot.setLocalScale(1,1,1);
207
-
208
- // Fix matériaux (au cas où) : s’assure d’un rendu PBR correct
209
- const renders = instance.findComponents('render');
210
- for (const r of renders) {
211
- r.castShadows = true; // l’objet projette des ombres (utile si tu actives de vraies ombres)
212
- for (const mi of r.meshInstances) {
213
- const m = mi.material;
214
- if (!m) continue;
215
- if (m.diffuse && (m.diffuse.r !== 1 || m.diffuse.g !== 1 || m.diffuse.b !== 1)) {
216
- m.diffuse.set(1,1,1);
 
217
  }
218
- if ('useSkybox' in m) m.useSkybox = true;
219
- m.update();
220
  }
221
  }
222
 
223
- const initE = modelRoot.getEulerAngles();
224
  baseEulerX = initE.x; baseEulerZ = initE.z;
225
 
226
  modelLoaded = true;
227
  message("Modèle chargé. Touchez l’écran pour démarrer l’AR.");
228
  });
229
 
230
- if (!app.xr.supported){ message("WebXR n’est pas supporté sur cet appareil."); return; }
231
 
232
  // ===== Slider fiable : Pointer Events en CAPTURE =====
233
- let uiInteracting=false;
234
- let draggingWrap=false;
235
- let activePointerId=null;
236
-
237
- const insideWrap = (target) => rotWrap.contains(target);
238
- const degFromPointer = (e) => {
239
- const rect = rotWrap.getBoundingClientRect();
240
- const y = (e.clientY != null) ? e.clientY : (e.touches && e.touches[0] && e.touches[0].clientY) || 0;
241
- const ratio = (y - rect.top) / rect.height; // 0 en haut -> 1 en bas
242
- const t = Math.max(0, Math.min(1, ratio));
243
- return (1 - t) * 360; // 360 en haut, 0 en bas
244
- };
245
-
246
- const onPointerDownCapture = (e) => {
247
  if (!insideWrap(e.target)) return;
248
  uiInteracting = true;
249
  draggingWrap = true;
250
- activePointerId = e.pointerId ?? 1;
251
- try { rotWrap.setPointerCapture?.(activePointerId); } catch {}
 
 
252
  applyRotationY(degFromPointer(e));
253
  e.preventDefault();
254
  e.stopPropagation();
255
- };
256
- const onPointerMoveCapture = (e) => {
257
- if (!draggingWrap || (e.pointerId ?? 1) !== activePointerId) return;
258
  applyRotationY(degFromPointer(e));
259
  e.preventDefault();
260
  e.stopPropagation();
261
- };
262
- const endDrag = (e) => {
263
- if (!draggingWrap || (e.pointerId ?? 1) !== activePointerId) return;
264
  draggingWrap = false;
265
  uiInteracting = false;
266
- try { rotWrap.releasePointerCapture?.(activePointerId); } catch {}
 
 
267
  activePointerId = null;
268
  e.preventDefault();
269
  e.stopPropagation();
270
- };
271
 
272
  document.addEventListener("pointerdown", onPointerDownCapture, { capture: true, passive: false });
273
- document.addEventListener("pointermove", onPointerMoveCapture, { capture: true, passive: false });
274
- document.addEventListener("pointerup", endDrag, { capture: true, passive: false });
275
- document.addEventListener("pointercancel", endDrag, { capture: true, passive: false });
276
-
277
- // --- Démarrage AR (sans light-estimation)
278
- const activateAR = () => {
279
- if (!app.xr.isAvailable(pc.XRTYPE_AR)){ message("AR immersive indisponible sur cet appareil."); return; }
 
280
  app.xr.domOverlay.root = document.getElementById("xr-overlay-root");
281
  camera.camera.startXr(pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, {
282
- requiredFeatures: ["hit-test","dom-overlay"],
283
  domOverlay: { root: app.xr.domOverlay.root },
284
- callback: (err) => { if (err){ console.error("Échec du démarrage AR :", err); message(`Échec du démarrage AR : ${err.message||err}`); } }
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  });
286
- };
287
- app.mouse.on("mousedown", ()=>{ if(!app.xr.active && !uiInteracting) activateAR(); });
288
- if (app.touch){
289
- app.touch.on("touchend", (evt)=>{ if(!app.xr.active && !uiInteracting) activateAR(); evt.event.preventDefault(); evt.event.stopPropagation(); });
290
  }
291
- app.keyboard.on("keydown", (evt)=>{ if (evt.key===pc.KEY_ESCAPE && app.xr.active) app.xr.end(); });
292
 
293
  // ====== Filtre HORIZONTAL uniquement ======
294
- const TMP_IN = new pc.Vec3(0,1,0), TMP_OUT = new pc.Vec3();
295
- function isHorizontalUpFacing(rot, minDot = 0.75){ rot.transformVector(TMP_IN, TMP_OUT); return TMP_OUT.y >= minDot; }
 
 
 
 
296
 
297
  // Hit Test global (réticule + 1er placement)
298
- app.xr.hitTest.on("available", () => {
299
  app.xr.hitTest.start({
300
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
301
- callback: (err, hitSource) => {
302
- if (err){ message("Le AR hit test n’a pas pu démarrer."); return; }
303
- hitSource.on("result", (pos, rot) => {
304
- if (!isHorizontalUpFacing(rot)) return; // horizontaux uniquement
305
 
306
  reticle.enabled = true;
307
  reticle.setPosition(pos);
308
  reticle.setRotation(rot);
309
 
310
- if (modelLoaded && !placedOnce){
311
  modelRoot.enabled = true;
312
  modelRoot.setPosition(pos);
313
 
314
- // Crée l’ombre de contact au même endroit/orientation
315
  blob = createBlobShadowAt(pos, rot);
316
 
317
- const e = new pc.Vec3(); rot.getEulerAngles(e);
318
- // normalise yaw initiale 0..360
319
- const y0 = ((e.y % 360)+360)%360;
320
  applyRotationY(y0);
321
 
322
  placedOnce = true;
@@ -329,62 +445,80 @@
329
  });
330
 
331
  // Déplacement XR (drag) — ignoré si UI active
332
- let isDragging=false;
333
- app.xr.input.on("add", (inputSource) => {
334
- inputSource.on("selectstart", () => {
335
  if (uiInteracting) return;
336
  if (!placedOnce || !modelLoaded) return;
337
 
338
  inputSource.hitTestStart({
339
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
340
- callback: (err, transientSource) => {
341
  if (err) return;
342
  isDragging = true;
343
 
344
- transientSource.on("result", (pos, rot) => {
345
  if (!isDragging) return;
346
  if (!isHorizontalUpFacing(rot)) return;
347
  modelRoot.setPosition(pos);
348
- updateBlobPositionUnder(pos, rot); // <— l’ombre suit le modèle
349
  });
350
 
351
- transientSource.once("remove", () => { isDragging = false; });
352
  }
353
  });
354
  });
355
- inputSource.on("selectend", () => { isDragging = false; });
356
  });
357
 
358
- // Desktop : rotation souris conservée (ignore si UI)
359
- let rotateMode=false, lastMouseX=0; const ROTATE_SENSITIVITY=0.25;
360
- 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; }});
361
- app.mouse.on("mousemove",(e)=>{
362
- if(!app.xr.active||!placedOnce||uiInteracting) return;
363
- if(isDragging){
364
- if(reticle.enabled) {
365
- const p = reticle.getPosition();
366
- modelRoot.setPosition(p);
367
- updateBlobPositionUnder(p); // <— suit aussi en drag souris via réticule
368
- }
369
- } else if(rotateMode && modelRoot.enabled){
370
- const dx=e.x-lastMouseX; lastMouseX=e.x;
371
- applyRotationY(rotationYDeg + dx*ROTATE_SENSITIVITY);
372
  }
373
  });
374
- app.mouse.on("mouseup",()=>{ isDragging=false; rotateMode=false; });
375
- window.addEventListener("contextmenu",(e)=>e.preventDefault());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
 
377
  // Slider (accessibilité clavier)
378
  rotYInput.disabled = true;
379
- rotYInput.addEventListener("input",(e)=>{ if(!modelRoot.enabled) return; applyRotationY(parseFloat(e.target.value||"0")); },{passive:true});
 
 
 
 
380
 
381
  // AR events
382
- app.xr.on("start",()=>{ message("Session AR démarrée. Visez le sol pour détecter un plan…"); reticle.enabled=true; });
383
- app.xr.on("end",()=>{ message("Session AR terminée."); reticle.enabled=false; isDragging=false; rotateMode=false; rotYInput.disabled=true; });
384
- app.xr.on(`available:${pc.XRTYPE_AR}`,(a)=>{ if(!a) 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…"); });
 
 
 
 
385
 
386
- if(!app.xr.isAvailable(pc.XRTYPE_AR)) message("AR immersive indisponible.");
387
- else if(!app.xr.hitTest.supported) message("AR Hit Test non supporté.");
388
  else message("Chargement du modèle…");
389
  }
390
  })();
 
6
  - Blob Shadow (ombre de contact) sous l’objet
7
  */
8
 
9
+ (function () {
10
  const GLB_URL = "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/AR/tests/danae_no_metallic.glb";
11
 
12
  // ===== PlayCanvas version fixée =====
13
  const PC_VERSION = "2.11.7";
14
  const PC_URLS = {
15
+ esm: ["https://cdn.jsdelivr.net/npm/playcanvas@" + PC_VERSION + "/build/playcanvas.min.mjs"],
16
+ umd: ["https://cdn.jsdelivr.net/npm/playcanvas@" + PC_VERSION + "/build/playcanvas.min.js"]
17
  };
18
 
19
+ function timeout(ms) {
20
+ return new Promise(function (_res, rej) {
21
+ setTimeout(function () { rej(new Error("timeout")); }, ms);
22
+ });
 
 
23
  }
24
 
25
+ async function loadPlayCanvasRobust(opts) {
26
+ opts = opts || {};
27
+ var esmFirst = (typeof opts.esmFirst === "boolean") ? opts.esmFirst : true;
28
+ var loadTimeoutMs = (typeof opts.loadTimeoutMs === "number") ? opts.loadTimeoutMs : 15000;
29
+
30
+ if (window.pc && window.pc.Application) return window.pc;
31
+
32
+ async function tryESM() {
33
+ for (var i = 0; i < PC_URLS.esm.length; i++) {
34
+ var url = PC_URLS.esm[i];
35
+ try {
36
+ var mod = await Promise.race([import(/* @vite-ignore */ url), timeout(loadTimeoutMs)]);
37
+ var ns = (mod && (mod.pc || mod["default"])) || mod;
38
+ if (ns && ns.Application) {
39
+ if (!window.pc) window.pc = ns;
40
+ return window.pc;
41
+ }
42
+ } catch (e) {
43
+ // continue
44
+ }
45
+ }
46
+ throw new Error("ESM failed");
47
+ }
48
 
49
+ async function tryUMD() {
50
+ for (var j = 0; j < PC_URLS.umd.length; j++) {
51
+ var url2 = PC_URLS.umd[j];
52
+ try {
53
+ await Promise.race([
54
+ new Promise(function (res, rej) {
55
+ var s = document.createElement("script");
56
+ s.src = url2;
57
+ s.async = true;
58
+ s.onload = function () { res(); };
59
+ s.onerror = function () { rej(new Error("script error")); };
60
+ document.head.appendChild(s);
61
+ }),
62
+ timeout(loadTimeoutMs)
63
+ ]);
64
+ if (window.pc && window.pc.Application) return window.pc;
65
+ } catch (e) {
66
+ // continue
67
+ }
68
+ }
69
+ throw new Error("UMD failed");
70
+ }
71
 
72
+ try {
73
+ if (esmFirst) return await tryESM();
74
+ return await tryUMD();
75
+ } catch (e) {
76
+ if (esmFirst) return await tryUMD();
77
+ return await tryESM();
78
+ }
79
+ }
80
 
81
+ // ===== UI / Overlay =====
82
+ var css = [
83
+ ".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}",
84
+ "#xr-overlay-root{position:fixed;inset:0;z-index:9999;pointer-events:none}",
85
+
86
+ ".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}",
87
+ ".ar-ui .label{font-size:12px;text-align:center;opacity:.95}",
88
+ ".rotY-wrap{position:relative;width:48px;height:200px;display:flex;align-items:center;justify-content:center;touch-action:none;overflow:visible;pointer-events:auto}",
89
+ ".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}",
90
+ ".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}",
91
+ ".ar-ui input[type=\"range\"].rotY{position:absolute;opacity:0;pointer-events:none;width:0;height:0}",
92
+ ".ar-ui .val{font-size:12px;opacity:.95}"
93
+ ].join("\n");
94
+ var styleTag = document.createElement("style");
95
+ styleTag.textContent = css;
96
+ document.head.appendChild(styleTag);
97
+
98
+ function ensureOverlayRoot() {
99
+ var r = document.getElementById("xr-overlay-root");
100
+ if (!r) {
101
+ r = document.createElement("div");
102
+ r.id = "xr-overlay-root";
103
+ document.body.appendChild(r);
104
+ }
105
+ return r;
106
+ }
107
+ var overlayRoot = ensureOverlayRoot();
108
+
109
+ function message(msg) {
110
+ var el = overlayRoot.querySelector(".pc-ar-msg");
111
+ if (!el) {
112
+ el = document.createElement("div");
113
+ el.className = "pc-ar-msg";
114
+ overlayRoot.appendChild(el);
115
+ }
116
+ el.textContent = msg;
117
+ }
118
 
119
+ function ensureCanvas() {
120
+ var c = document.getElementById("application-canvas");
121
+ if (!c) {
122
+ c = document.createElement("canvas");
123
+ c.id = "application-canvas";
124
+ c.style.width = "100%";
125
+ c.style.height = "100%";
126
+ document.body.appendChild(c);
127
+ }
128
+ return c;
129
+ }
130
 
131
+ function ensureSliderUI() {
132
+ var p = overlayRoot.querySelector(".ar-ui");
133
+ if (p) return p;
134
+ p = document.createElement("div");
135
+ p.className = "ar-ui";
136
+ p.innerHTML =
137
+ '<div class="label">Rotation</div>' +
138
+ '<div class="rotY-wrap" id="ar-rotY-wrap">' +
139
+ ' <div class="rotY-rail"></div>' +
140
+ ' <div class="rotY-knob" id="ar-rotY-knob"></div>' +
141
+ ' <input class="rotY" id="ar-rotY" type="range" min="0" max="360" step="1" value="0"/>' +
142
+ '</div>' +
143
+ '<div class="val" id="ar-rotY-val">0°</div>';
144
  overlayRoot.appendChild(p);
145
  return p;
146
  }
147
 
148
  // ===== Boot =====
149
+ (async function () {
150
+ try {
151
+ await loadPlayCanvasRobust({ esmFirst: true, loadTimeoutMs: 15000 });
152
+ } catch (e) {
153
+ console.error("Chargement PlayCanvas échoué ->", e);
154
+ message("Impossible de charger PlayCanvas (réseau/CDN). Réessaie plus tard.");
155
+ return;
156
+ }
157
+ initARApp();
158
+ })();
159
 
160
  // ===== App =====
161
+ function initARApp() {
162
+ var pc = window.pc;
163
+ var canvas = ensureCanvas();
164
+ var ui = ensureSliderUI();
165
+ var rotWrap = ui.querySelector("#ar-rotY-wrap");
166
+ var rotKnob = ui.querySelector("#ar-rotY-knob");
167
+ var rotYInput = ui.querySelector("#ar-rotY");
168
+ var rotYVal = ui.querySelector("#ar-rotY-val");
169
 
170
  window.focus();
171
 
172
+ var app = new pc.Application(canvas, {
173
  mouse: new pc.Mouse(canvas),
174
  touch: new pc.TouchDevice(canvas),
175
  keyboard: new pc.Keyboard(window),
 
178
  app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
179
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
180
  app.graphicsDevice.maxPixelRatio = window.devicePixelRatio || 1;
181
+ var onResize = function () { app.resizeCanvas(); };
182
+ window.addEventListener("resize", onResize);
183
+ app.on("destroy", function () { window.removeEventListener("resize", onResize); });
184
  app.start();
185
 
186
  // ===== Rendu / PBR defaults =====
187
  app.scene.gammaCorrection = pc.GAMMA_SRGB;
188
+ app.scene.toneMapping = pc.TONEMAP_ACES;
189
+ app.scene.exposure = 1;
190
+ app.scene.ambientLight = new pc.Color(1, 1, 1);
191
 
192
+ // Camera + lumière
193
+ var camera = new pc.Entity("Camera");
194
+ camera.addComponent("camera", { clearColor: new pc.Color(0, 0, 0, 0), farClip: 10000 });
195
  app.root.addChild(camera);
196
 
197
+ var light = new pc.Entity("Light");
198
+ light.addComponent("light", { type: "directional", intensity: 1.0, castShadows: true, color: new pc.Color(1, 1, 1) });
199
  light.setLocalEulerAngles(45, 30, 0);
200
  app.root.addChild(light);
201
 
202
  // Réticule
203
+ var reticleMat = new pc.StandardMaterial();
204
+ reticleMat.diffuse = new pc.Color(0.2, 0.8, 1.0);
205
+ reticleMat.opacity = 0.85;
206
+ reticleMat.blendType = pc.BLEND_NORMAL;
207
+ reticleMat.update();
208
+
209
+ var reticle = new pc.Entity("Reticle");
210
+ reticle.addComponent("render", { type: "torus", material: reticleMat });
211
+ reticle.setLocalScale(0.12, 0.005, 0.12);
212
+ reticle.enabled = false;
213
+ app.root.addChild(reticle);
214
 
215
  // Modèle
216
+ var modelRoot = new pc.Entity("ModelRoot");
217
+ modelRoot.enabled = false;
218
+ app.root.addChild(modelRoot);
219
+ var modelLoaded = false, placedOnce = false;
220
 
221
  // ===== Blob Shadow =====
222
+ var blob = null; // entité plane de l’ombre
223
+ var BLOB_SIZE = 0.7;
224
+ var BLOB_OFFSET_Y = 0.005;
225
 
226
+ function makeBlobTexture(app, size) {
227
+ size = size || 256;
228
+ var cvs = document.createElement('canvas');
229
  cvs.width = cvs.height = size;
230
+ var ctx = cvs.getContext('2d');
231
+ var r = size * 0.45;
232
+ var grd = ctx.createRadialGradient(size / 2, size / 2, r * 0.2, size / 2, size / 2, r);
233
+ grd.addColorStop(0, 'rgba(0,0,0,0.35)');
234
+ grd.addColorStop(1, 'rgba(0,0,0,0.0)');
235
  ctx.fillStyle = grd;
236
  ctx.fillRect(0, 0, size, size);
237
 
238
+ var tex = new pc.Texture(app.graphicsDevice, {
239
+ width: size,
240
+ height: size,
241
+ format: pc.PIXELFORMAT_R8_G8_B8_A8,
242
+ mipmaps: true,
243
+ magFilter: pc.FILTER_LINEAR,
244
+ minFilter: pc.FILTER_LINEAR_MIPMAP_LINEAR
245
  });
246
  tex.setSource(cvs);
247
  return tex;
248
  }
249
 
250
  function createBlobShadowAt(pos, rot) {
251
+ var tex = makeBlobTexture(app, 256);
252
+ var blobMat = new pc.StandardMaterial();
253
+ blobMat.diffuse = new pc.Color(0, 0, 0);
254
+ blobMat.opacity = 1.0;
255
  blobMat.blendType = pc.BLEND_PREMULTIPLIED;
256
+ blobMat.depthWrite = false;
257
  blobMat.diffuseMap = tex;
258
  blobMat.update();
259
 
260
+ var e = new pc.Entity("BlobShadow");
261
  e.addComponent("render", { type: "plane", castShadows: false, receiveShadows: false });
262
  e.render.material = blobMat;
263
 
 
264
  e.setPosition(pos.x, pos.y + BLOB_OFFSET_Y, pos.z);
265
  e.setRotation(rot);
266
  e.setLocalScale(BLOB_SIZE, 1, BLOB_SIZE);
 
269
  return e;
270
  }
271
 
272
+ // Euler base
273
+ var baseEulerX = 0, baseEulerZ = 0;
274
 
275
+ // Rotation via slider
276
+ var rotationYDeg = 0;
277
+ function clamp360(d) { return Math.max(0, Math.min(360, d)); }
278
 
279
+ function updateKnobFromY(yDeg) {
280
+ var t = 1 - (yDeg / 360);
281
+ rotKnob.style.top = String(t * 100) + "%";
282
  rotYInput.value = String(Math.round(yDeg));
283
+ rotYVal.textContent = String(Math.round(yDeg)) + "°";
284
  }
285
+ function applyRotationY(deg) {
286
+ var y = clamp360(deg);
287
  rotationYDeg = y;
288
  modelRoot.setEulerAngles(baseEulerX, y, baseEulerZ);
289
  updateKnobFromY(y);
290
  }
291
 
292
+ function updateBlobPositionUnder(pos, rotLikePlane) {
 
293
  if (!blob) return;
 
294
  blob.setPosition(pos.x, pos.y + BLOB_OFFSET_Y, pos.z);
295
+ if (rotLikePlane) blob.setRotation(rotLikePlane);
 
 
296
  }
297
 
298
+ // Chargement GLB
299
+ app.assets.loadFromUrl(GLB_URL, "container", function (err, asset) {
300
+ if (err) { console.error(err); message("Échec du chargement du modèle GLB."); return; }
301
+ var instance = asset.resource.instantiateRenderEntity({ castShadows: true, receiveShadows: false });
302
  modelRoot.addChild(instance);
303
+ modelRoot.setLocalScale(1, 1, 1);
304
+
305
+ // Fix matériaux
306
+ var renders = instance.findComponents('render');
307
+ for (var ri = 0; ri < renders.length; ri++) {
308
+ var r = renders[ri];
309
+ r.castShadows = true;
310
+ for (var mi = 0; mi < r.meshInstances.length; mi++) {
311
+ var mat = r.meshInstances[mi].material;
312
+ if (!mat) continue;
313
+ if (mat.diffuse && (mat.diffuse.r !== 1 || mat.diffuse.g !== 1 || mat.diffuse.b !== 1)) {
314
+ mat.diffuse.set(1, 1, 1);
315
  }
316
+ if (typeof mat.useSkybox !== "undefined") mat.useSkybox = true;
317
+ mat.update();
318
  }
319
  }
320
 
321
+ var initE = modelRoot.getEulerAngles();
322
  baseEulerX = initE.x; baseEulerZ = initE.z;
323
 
324
  modelLoaded = true;
325
  message("Modèle chargé. Touchez l’écran pour démarrer l’AR.");
326
  });
327
 
328
+ if (!app.xr.supported) { message("WebXR n’est pas supporté sur cet appareil."); return; }
329
 
330
  // ===== Slider fiable : Pointer Events en CAPTURE =====
331
+ var uiInteracting = false;
332
+ var draggingWrap = false;
333
+ var activePointerId = null;
334
+
335
+ function insideWrap(target) { return rotWrap.contains(target); }
336
+ function degFromPointer(e) {
337
+ var rect = rotWrap.getBoundingClientRect();
338
+ var y = (e.clientY != null) ? e.clientY : ((e.touches && e.touches[0] && e.touches[0].clientY) || 0);
339
+ var ratio = (y - rect.top) / rect.height;
340
+ var t = Math.max(0, Math.min(1, ratio));
341
+ return (1 - t) * 360;
342
+ }
343
+
344
+ function onPointerDownCapture(e) {
345
  if (!insideWrap(e.target)) return;
346
  uiInteracting = true;
347
  draggingWrap = true;
348
+ activePointerId = (e.pointerId != null) ? e.pointerId : 1;
349
+ if (rotWrap.setPointerCapture) {
350
+ try { rotWrap.setPointerCapture(activePointerId); } catch (er) {}
351
+ }
352
  applyRotationY(degFromPointer(e));
353
  e.preventDefault();
354
  e.stopPropagation();
355
+ }
356
+ function onPointerMoveCapture(e) {
357
+ if (!draggingWrap || ((e.pointerId != null ? e.pointerId : 1) !== activePointerId)) return;
358
  applyRotationY(degFromPointer(e));
359
  e.preventDefault();
360
  e.stopPropagation();
361
+ }
362
+ function endDrag(e) {
363
+ if (!draggingWrap || ((e.pointerId != null ? e.pointerId : 1) !== activePointerId)) return;
364
  draggingWrap = false;
365
  uiInteracting = false;
366
+ if (rotWrap.releasePointerCapture) {
367
+ try { rotWrap.releasePointerCapture(activePointerId); } catch (er) {}
368
+ }
369
  activePointerId = null;
370
  e.preventDefault();
371
  e.stopPropagation();
372
+ }
373
 
374
  document.addEventListener("pointerdown", onPointerDownCapture, { capture: true, passive: false });
375
+ document.addEventListener("pointermove", onPointerMoveCapture, { capture: true, passive: false });
376
+ document.addEventListener("pointerup", endDrag, { capture: true, passive: false });
377
+ document.addEventListener("pointercancel", endDrag, { capture: true, passive: false });
378
+
379
+ // --- Démarrage AR
380
+ function activateAR() {
381
+ if (!app.xr.isAvailable(pc.XRTYPE_AR)) { message("AR immersive indisponible sur cet appareil."); return; }
382
+ if (!app.xr.domOverlay) app.xr.domOverlay = {};
383
  app.xr.domOverlay.root = document.getElementById("xr-overlay-root");
384
  camera.camera.startXr(pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, {
385
+ requiredFeatures: ["hit-test", "dom-overlay"],
386
  domOverlay: { root: app.xr.domOverlay.root },
387
+ callback: function (err) {
388
+ if (err) {
389
+ console.error("Échec du démarrage AR :", err);
390
+ message("Échec du démarrage AR : " + (err.message || err));
391
+ }
392
+ }
393
+ });
394
+ }
395
+ app.mouse.on("mousedown", function () { if (!app.xr.active && !uiInteracting) activateAR(); });
396
+ if (app.touch) {
397
+ app.touch.on("touchend", function (evt) {
398
+ if (!app.xr.active && !uiInteracting) activateAR();
399
+ evt.event.preventDefault();
400
+ evt.event.stopPropagation();
401
  });
 
 
 
 
402
  }
403
+ app.keyboard.on("keydown", function (evt) { if (evt.key === pc.KEY_ESCAPE && app.xr.active) app.xr.end(); });
404
 
405
  // ====== Filtre HORIZONTAL uniquement ======
406
+ var TMP_IN = new pc.Vec3(0, 1, 0), TMP_OUT = new pc.Vec3();
407
+ function isHorizontalUpFacing(rot, minDot) {
408
+ minDot = (typeof minDot === "number") ? minDot : 0.75;
409
+ rot.transformVector(TMP_IN, TMP_OUT);
410
+ return TMP_OUT.y >= minDot;
411
+ }
412
 
413
  // Hit Test global (réticule + 1er placement)
414
+ app.xr.hitTest.on("available", function () {
415
  app.xr.hitTest.start({
416
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
417
+ callback: function (err, hitSource) {
418
+ if (err) { message("Le AR hit test n’a pas pu démarrer."); return; }
419
+ hitSource.on("result", function (pos, rot) {
420
+ if (!isHorizontalUpFacing(rot)) return;
421
 
422
  reticle.enabled = true;
423
  reticle.setPosition(pos);
424
  reticle.setRotation(rot);
425
 
426
+ if (modelLoaded && !placedOnce) {
427
  modelRoot.enabled = true;
428
  modelRoot.setPosition(pos);
429
 
430
+ // Ombre de contact au placement initial
431
  blob = createBlobShadowAt(pos, rot);
432
 
433
+ var e = new pc.Vec3();
434
+ rot.getEulerAngles(e);
435
+ var y0 = ((e.y % 360) + 360) % 360;
436
  applyRotationY(y0);
437
 
438
  placedOnce = true;
 
445
  });
446
 
447
  // Déplacement XR (drag) — ignoré si UI active
448
+ var isDragging = false;
449
+ app.xr.input.on("add", function (inputSource) {
450
+ inputSource.on("selectstart", function () {
451
  if (uiInteracting) return;
452
  if (!placedOnce || !modelLoaded) return;
453
 
454
  inputSource.hitTestStart({
455
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
456
+ callback: function (err, transientSource) {
457
  if (err) return;
458
  isDragging = true;
459
 
460
+ transientSource.on("result", function (pos, rot) {
461
  if (!isDragging) return;
462
  if (!isHorizontalUpFacing(rot)) return;
463
  modelRoot.setPosition(pos);
464
+ updateBlobPositionUnder(pos, rot);
465
  });
466
 
467
+ transientSource.once("remove", function () { isDragging = false; });
468
  }
469
  });
470
  });
471
+ inputSource.on("selectend", function () { isDragging = false; });
472
  });
473
 
474
+ // Desktop : rotation souris (ignore si UI)
475
+ var rotateMode = false, lastMouseX = 0;
476
+ var ROTATE_SENSITIVITY = 0.25;
477
+ app.mouse.on("mousedown", function (e) {
478
+ if (!app.xr.active || !placedOnce || uiInteracting) return;
479
+ if (e.button === 0 && !e.shiftKey) {
480
+ isDragging = true;
481
+ } else if (e.button === 2 || (e.button === 0 && e.shiftKey)) {
482
+ rotateMode = true;
483
+ lastMouseX = e.x;
 
 
 
 
484
  }
485
  });
486
+ app.mouse.on("mousemove", function (e) {
487
+ if (!app.xr.active || !placedOnce || uiInteracting) return;
488
+ if (isDragging) {
489
+ if (reticle.enabled) {
490
+ var p = reticle.getPosition();
491
+ modelRoot.setPosition(p);
492
+ updateBlobPositionUnder(p, null);
493
+ }
494
+ } else if (rotateMode && modelRoot.enabled) {
495
+ var dx = e.x - lastMouseX;
496
+ lastMouseX = e.x;
497
+ applyRotationY(rotationYDeg + dx * ROTATE_SENSITIVITY);
498
+ }
499
+ });
500
+ app.mouse.on("mouseup", function () { isDragging = false; rotateMode = false; });
501
+ window.addEventListener("contextmenu", function (e) { e.preventDefault(); });
502
 
503
  // Slider (accessibilité clavier)
504
  rotYInput.disabled = true;
505
+ rotYInput.addEventListener("input", function (e) {
506
+ if (!modelRoot.enabled) return;
507
+ var v = parseFloat(e.target.value || "0");
508
+ applyRotationY(v);
509
+ }, { passive: true });
510
 
511
  // AR events
512
+ app.xr.on("start", function () { message("Session AR démarrée. Visez le sol pour détecter un plan…"); reticle.enabled = true; });
513
+ app.xr.on("end", function () { message("Session AR terminée."); reticle.enabled = false; isDragging = false; rotateMode = false; rotYInput.disabled = true; });
514
+ app.xr.on("available:" + pc.XRTYPE_AR, function (a) {
515
+ if (!a) message("AR immersive indisponible.");
516
+ else if (!app.xr.hitTest.supported) message("AR Hit Test non supporté.");
517
+ else message(modelLoaded ? "Touchez l’écran pour démarrer l’AR." : "Chargement du modèle…");
518
+ });
519
 
520
+ if (!app.xr.isAvailable(pc.XRTYPE_AR)) message("AR immersive indisponible.");
521
+ else if (!app.xr.hitTest.supported) message("AR Hit Test non supporté.");
522
  else message("Chargement du modèle…");
523
  }
524
  })();