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

Update viewer_ar.js

Browse files
Files changed (1) hide show
  1. viewer_ar.js +173 -116
viewer_ar.js CHANGED
@@ -1,12 +1,13 @@
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`],
@@ -15,32 +16,42 @@
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;
@@ -49,6 +60,7 @@
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>`;
@@ -56,132 +68,167 @@
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,39 +236,49 @@
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…");
 
1
  /* script_ar.js — AR PlayCanvas + GLB HuggingFace
2
  - Version PC verrouillée
3
+ - WebXR AR Hit Test (HORIZONTAL UNIQUEMENT), placement auto, drag
4
+ - Rotation Y via SLIDER custom (knob centré, rail plein, 0→360 sans wrap)
5
  */
6
 
7
  (() => {
8
  const GLB_URL = "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/AR/tests/fraisier.glb";
9
 
10
+ // ===== PlayCanvas chargé en version fixe =====
11
  const PC_VERSION = "2.11.7";
12
  const PC_URLS = {
13
  esm: [`https://cdn.jsdelivr.net/npm/playcanvas@${PC_VERSION}/build/playcanvas.min.mjs`],
 
16
 
17
  function timeout(ms){return new Promise((_,rej)=>setTimeout(()=>rej(new Error("timeout")),ms));}
18
  async function loadPlayCanvasRobust({esmFirst=true,loadTimeoutMs=15000}={}){
19
+ if (window.pc?.Application) return window.pc;
20
+ 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"); };
21
+ 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"); };
22
  try{ return esmFirst ? await tryESM() : await tryUMD(); }catch{ return esmFirst ? await tryUMD() : await tryESM(); }
23
  }
24
 
25
+ // ===== UI / Overlay =====
26
  const css = `
27
  .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}
28
  #xr-overlay-root{position:fixed;inset:0;z-index:9999;pointer-events:none}
29
+
30
+ .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}
31
+
32
  .ar-ui .label{font-size:12px;text-align:center;opacity:.95}
33
+
34
+ /* Grande zone tactile */
35
+ .rotY-wrap{position:relative;width:48px;height:200px;display:flex;align-items:center;justify-content:center;touch-action:none;overflow:visible}
36
+
37
+ /* Rail PLEIN (de haut en bas), centré */
38
+ .rotY-rail{position:absolute;left:50%;transform:translateX(-50%);width:4px;height:100%;background:rgba(255,255,255,.35);border-radius:2px}
39
+
40
+ /* Knob custom bien centré */
41
+ .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}
42
+
43
+ /* Input range caché (on s'en sert seulement pour valeur/keyboard) */
44
+ .ar-ui input[type="range"].rotY{position:absolute;opacity:0;pointer-events:none;width:0;height:0}
45
  .ar-ui .val{font-size:12px;opacity:.95}
46
  `;
47
+ const styleTag = document.createElement("style"); styleTag.textContent = css; document.head.appendChild(styleTag);
48
 
49
+ 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; }
50
+ const overlayRoot = ensureOverlayRoot();
51
 
52
+ 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; }
53
 
54
+ 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; }
55
 
56
  function ensureSliderUI(){
57
  let p=overlayRoot.querySelector(".ar-ui"); if(p) return p;
 
60
  <div class="label">Rotation</div>
61
  <div class="rotY-wrap" id="ar-rotY-wrap">
62
  <div class="rotY-rail"></div>
63
+ <div class="rotY-knob" id="ar-rotY-knob"></div>
64
  <input class="rotY" id="ar-rotY" type="range" min="0" max="360" step="1" value="0"/>
65
  </div>
66
  <div class="val" id="ar-rotY-val">0°</div>`;
 
68
  return p;
69
  }
70
 
71
+ // ===== Boot =====
72
+ (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(); })();
73
 
74
+ // ===== App =====
75
  function initARApp(){
76
+ const pc = window.pc;
77
+ const canvas = ensureCanvas();
78
+ const ui = ensureSliderUI();
79
+ const rotWrap = ui.querySelector("#ar-rotY-wrap");
80
+ const rotKnob = ui.querySelector("#ar-rotY-knob");
81
+ const rotYInput = ui.querySelector("#ar-rotY");
82
+ const rotYVal = ui.querySelector("#ar-rotY-val");
83
 
84
  window.focus();
85
 
86
+ const app = new pc.Application(canvas, {
87
+ mouse: new pc.Mouse(canvas),
88
+ touch: new pc.TouchDevice(canvas),
89
+ keyboard: new pc.Keyboard(window),
90
+ graphicsDeviceOptions: { alpha: true }
91
+ });
92
  app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
93
  app.setCanvasResolution(pc.RESOLUTION_AUTO);
94
+ app.graphicsDevice.maxPixelRatio = window.devicePixelRatio || 1;
95
+ const onResize = () => app.resizeCanvas(); window.addEventListener("resize", onResize); app.on("destroy",()=>window.removeEventListener("resize",onResize));
96
  app.start();
97
 
98
+ // Camera + lumière
99
+ const camera = new pc.Entity("Camera");
100
+ camera.addComponent("camera", { clearColor: new pc.Color(0,0,0,0), farClip: 10000 }); app.root.addChild(camera);
101
+ const light = new pc.Entity("Light");
102
+ light.addComponent("light", { type: "spot", range: 30, intensity: 1.1, castShadows: false }); light.setLocalPosition(0,10,0); app.root.addChild(light);
103
 
104
  // Réticule
105
+ 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();
106
+ 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);
107
 
108
  // Modèle
109
+ const modelRoot = new pc.Entity("ModelRoot"); modelRoot.enabled = false; app.root.addChild(modelRoot);
110
  let modelLoaded=false, placedOnce=false;
111
 
112
+ // Euler de base (évite les inversions)
113
+ let baseEulerX=0, baseEulerZ=0;
114
 
115
+ // Rotation Y (clamp 0..360)
116
+ let rotationYDeg = 0;
117
  const clamp360 = d => Math.max(0, Math.min(360, d));
 
118
 
119
+ // met à jour modèle + UI (knob plein rail)
120
+ function updateKnobFromY(yDeg){
121
+ const t = yDeg / 360; // 0 en haut (0°) → 1 en bas (360°)
122
+ rotKnob.style.top = `${t*100}%`; // knob va de haut en bas
123
+ rotYInput.value = String(Math.round(yDeg));
124
+ rotYVal.textContent = `${Math.round(yDeg)}°`;
125
+ }
126
  function applyRotationY(deg){
127
  const y = clamp360(deg);
128
  rotationYDeg = y;
129
  modelRoot.setEulerAngles(baseEulerX, y, baseEulerZ);
130
+ updateKnobFromY(y);
 
131
  }
132
 
133
  // Chargement GLB
134
+ app.assets.loadFromUrl(GLB_URL, "container", (err, asset) => {
135
+ if (err){ console.error(err); message("Échec du chargement du modèle GLB."); return; }
136
+ const instance = asset.resource.instantiateRenderEntity({ castShadows:false, receiveShadows:false });
137
  modelRoot.addChild(instance);
138
  modelRoot.setLocalScale(0.2,0.2,0.2);
139
 
 
140
  const initE = modelRoot.getEulerAngles();
141
  baseEulerX = initE.x; baseEulerZ = initE.z;
142
 
143
+ modelLoaded = true;
144
  message("Modèle chargé. Touchez l’écran pour démarrer l’AR.");
145
  });
146
 
147
+ // WebXR dispo ?
148
+ if (!app.xr.supported){ message("WebXR n’est pas supporté sur cet appareil."); return; }
149
 
150
+ // ——— UI: empêcher les événements d'aller à la scène
151
  let uiInteracting=false, draggingWrap=false;
152
+ const stop = e => { e.stopPropagation(); e.preventDefault(); };
153
  ["pointerdown","pointermove","pointerup","pointercancel","touchstart","touchmove","touchend","touchcancel","mousedown","mousemove","mouseup","wheel","click"].forEach(evt=>{
154
+ ui.addEventListener(evt, stop, {passive:false});
155
+ rotYInput.addEventListener(evt, stop, {passive:false});
156
+ rotKnob.addEventListener(evt, stop, {passive:false});
157
+ rotWrap.addEventListener(evt, (evt==="pointerdown"||evt==="touchstart"||evt==="mousedown")?(()=>{}):stop, {passive:false});
158
  });
159
+
160
+ // Drag custom sur le wrapper vertical
161
  function valueFromWrapEvent(ev){
162
+ const rect = rotWrap.getBoundingClientRect();
163
+ const clientY = (ev.touches && ev.touches[0]) ? ev.touches[0].clientY : ev.clientY;
164
+ const ratio = (clientY - rect.top) / rect.height; // 0 en haut1 en bas
165
+ const t = Math.max(0, Math.min(1, ratio));
166
+ return t * 360; // 0..360
167
  }
168
+ function beginWrapDrag(e){ uiInteracting=true; draggingWrap=true; stop(e); rotWrap.setPointerCapture?.(e.pointerId||1); applyRotationY(valueFromWrapEvent(e)); }
169
+ function moveWrapDrag(e){ if(!draggingWrap) return; stop(e); applyRotationY(valueFromWrapEvent(e)); }
170
+ function endWrapDrag(e){ if(!draggingWrap) return; stop(e); draggingWrap=false; uiInteracting=false; try{ rotWrap.releasePointerCapture?.(e.pointerId||1);}catch{} }
171
+
172
+ rotWrap.addEventListener("pointerdown", beginWrapDrag, {passive:false});
173
+ rotWrap.addEventListener("pointermove", moveWrapDrag, {passive:false});
174
+ rotWrap.addEventListener("pointerup", endWrapDrag, {passive:false});
175
+ rotWrap.addEventListener("pointercancel",endWrapDrag, {passive:false});
176
+ rotWrap.addEventListener("touchstart", beginWrapDrag, {passive:false});
177
+ rotWrap.addEventListener("touchmove", moveWrapDrag, {passive:false});
178
+ rotWrap.addEventListener("touchend", endWrapDrag, {passive:false});
179
+ rotWrap.addEventListener("touchcancel", endWrapDrag, {passive:false});
180
+
181
+ // ——— Démarrage AR
182
+ const activateAR = () => {
183
+ if (!app.xr.isAvailable(pc.XRTYPE_AR)){ message("AR immersive indisponible sur cet appareil."); return; }
184
+ app.xr.domOverlay.root = document.getElementById("xr-overlay-root");
185
+ camera.camera.startXr(pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, {
186
+ requiredFeatures: ["hit-test","dom-overlay"],
187
+ domOverlay: { root: app.xr.domOverlay.root },
188
+ callback: (err) => { if (err){ console.error("Échec du démarrage AR :", err); message(`Échec du démarrage AR : ${err.message||err}`); } }
189
  });
190
  };
191
+ app.mouse.on("mousedown", ()=>{ if(!app.xr.active && !uiInteracting) activateAR(); });
192
+ if (app.touch){
193
+ app.touch.on("touchend", (evt)=>{ if(!app.xr.active && !uiInteracting) activateAR(); evt.event.preventDefault(); evt.event.stopPropagation(); });
194
+ }
195
+ app.keyboard.on("keydown", (evt)=>{ if (evt.key===pc.KEY_ESCAPE && app.xr.active) app.xr.end(); });
196
 
197
+ // ====== Filtre HORIZONTAL uniquement ======
198
+ const V_UP = new pc.Vec3(0,1,0);
199
+ const TMP_IN = new pc.Vec3(0,1,0);
200
+ const TMP_OUT = new pc.Vec3();
 
 
 
 
201
 
202
+ function isHorizontalUpFacing(rot, minDot = 0.75){ // ~> <= ~41°
203
+ rot.transformVector(TMP_IN, TMP_OUT); // normale du plan (Y local → monde)
204
+ // on veut une normale orientée vers +Y (sol/table), pas murs/plafond
205
+ return TMP_OUT.y >= minDot;
206
+ }
207
 
208
+ // --- Hit Test global (réticule + 1er placement)
209
+ app.xr.hitTest.on("available", () => {
210
+ app.xr.hitTest.start({
211
+ entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
212
+ callback: (err, hitSource) => {
213
+ if (err){ message("Le AR hit test n’a pas pu démarrer."); return; }
214
+ hitSource.on("result", (pos, rot) => {
215
+ // filtre: uniquement horizontaux up-facing
216
+ if (!isHorizontalUpFacing(rot)) return;
217
+
218
+ reticle.enabled = true;
219
+ reticle.setPosition(pos);
220
+ reticle.setRotation(rot);
221
+
222
+ if (modelLoaded && !placedOnce){
223
+ modelRoot.enabled = true;
224
+ modelRoot.setPosition(pos);
225
+
226
+ // Yaw initiale normalisée (sans wrap) + maj UI
227
  const e = new pc.Vec3(); rot.getEulerAngles(e);
228
+ applyRotationY(clamp360((e.y % 360 + 360) % 360));
 
229
 
230
+ placedOnce = true;
231
+ rotYInput.disabled = false;
232
  message("Objet placé. Glissez pour déplacer, tournez-le avec le slider →");
233
  }
234
  });
 
236
  });
237
  });
238
 
239
+ // --- Déplacement XR (drag) — ignore si UI
240
  let isDragging=false;
241
+ app.xr.input.on("add", (inputSource) => {
242
+ inputSource.on("selectstart", () => {
243
+ if (uiInteracting) return;
244
+ if (!placedOnce || !modelLoaded) return;
245
+
246
  inputSource.hitTestStart({
247
+ entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
248
+ callback: (err, transientSource) => {
249
+ if (err) return;
250
+ isDragging = true;
251
+
252
+ transientSource.on("result", (pos, rot) => {
253
+ if (!isDragging) return;
254
+ if (!isHorizontalUpFacing(rot)) return; // <- rejet vertical
255
+ modelRoot.setPosition(pos);
256
+ });
257
+
258
+ transientSource.once("remove", () => { isDragging = false; });
259
  }
260
  });
261
  });
262
+ inputSource.on("selectend", () => { isDragging = false; });
263
  });
264
 
265
+ // --- Desktop : rotation souris conservée
266
+ let rotateMode=false, lastMouseX=0; const ROTATE_SENSITIVITY=0.25;
267
+ 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; }});
268
+ 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); }});
269
+ app.mouse.on("mouseup",()=>{ isDragging=false; rotateMode=false; });
270
  window.addEventListener("contextmenu",(e)=>e.preventDefault());
271
 
272
+ // --- Slider (si changé via clavier)
273
+ rotYInput.disabled = true;
274
  rotYInput.addEventListener("input",(e)=>{ if(!modelRoot.enabled) return; applyRotationY(parseFloat(e.target.value||"0")); },{passive:true});
275
 
276
+ // AR events
277
+ app.xr.on("start",()=>{ message("Session AR démarrée. Visez le sol pour détecter un plan…"); reticle.enabled=true; });
278
+ app.xr.on("end",()=>{ message("Session AR terminée."); reticle.enabled=false; isDragging=false; rotateMode=false; rotYInput.disabled=true; });
279
+ 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…"); });
280
 
281
+ // Message initial
282
  if(!app.xr.isAvailable(pc.XRTYPE_AR)) message("AR immersive indisponible.");
283
  else if(!app.xr.hitTest.supported) message("AR Hit Test non supporté.");
284
  else message("Chargement du modèle…");