MikaFil commited on
Commit
b150fbb
·
verified ·
1 Parent(s): 7c90387

Update viewer_ar.js

Browse files
Files changed (1) hide show
  1. viewer_ar.js +152 -95
viewer_ar.js CHANGED
@@ -1,19 +1,83 @@
1
- // script_ar.js
 
 
 
 
 
2
  (() => {
3
- const PLAYCANVAS_CDN = "https://cdn.playcanvas.com/playcanvas-stable.min.js";
 
 
4
  const GLB_URL = "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/AR/tests/fraisier.glb";
5
 
6
- // ============ Utils DOM ============
7
- const ensureCanvas = () => {
8
- let canvas = document.getElementById("application-canvas");
9
- if (!canvas) {
10
- canvas = document.createElement("canvas");
11
- canvas.id = "application-canvas";
12
- document.body.appendChild(canvas);
13
- }
14
- return canvas;
 
 
 
 
15
  };
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  const css = `
18
  .pc-ar-msg {
19
  position: fixed; left: 50%; transform: translateX(-50%);
@@ -21,16 +85,18 @@
21
  background: rgba(0,0,0,.65); color: #fff; border-radius: 8px;
22
  font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
23
  font-size: 14px; line-height: 1.3; text-align: center;
24
- max-width: min(90vw, 600px);
25
  box-shadow: 0 6px 20px rgba(0,0,0,.25);
26
  backdrop-filter: blur(4px);
 
 
27
  }
28
  `;
29
  const styleTag = document.createElement("style");
30
  styleTag.textContent = css;
31
  document.head.appendChild(styleTag);
32
 
33
- const message = (msg) => {
34
  let el = document.querySelector(".pc-ar-msg");
35
  if (!el) {
36
  el = document.createElement("div");
@@ -38,38 +104,49 @@
38
  document.body.appendChild(el);
39
  }
40
  el.textContent = msg;
41
- };
42
-
43
- // ============ Loader PlayCanvas ============
44
- const loadPcIfNeeded = () =>
45
- new Promise((resolve, reject) => {
46
- if (window.pc) return resolve();
47
- const s = document.createElement("script");
48
- s.src = PLAYCANVAS_CDN;
49
- s.async = true;
50
- s.onload = () => resolve();
51
- s.onerror = () => reject(new Error("Échec du chargement de PlayCanvas"));
52
- document.head.appendChild(s);
53
- });
54
 
55
- // ============ Main ============
56
- loadPcIfNeeded()
57
- .then(() => init())
58
- .catch((e) => {
59
- console.error(e);
60
- message("Impossible de charger PlayCanvas.");
61
- });
 
 
 
 
 
62
 
63
- function init() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  const canvas = ensureCanvas();
65
  window.focus();
66
 
67
- // Crée l'app PlayCanvas (fond transparent pour l'AR)
68
  const app = new pc.Application(canvas, {
69
  mouse: new pc.Mouse(canvas),
70
  touch: new pc.TouchDevice(canvas),
71
  keyboard: new pc.Keyboard(window),
72
- graphicsDeviceOptions: { alpha: true },
73
  });
74
 
75
  app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
@@ -82,26 +159,25 @@
82
 
83
  app.start();
84
 
85
- // Caméra
86
  const camera = new pc.Entity("Camera");
87
  camera.addComponent("camera", {
88
  clearColor: new pc.Color(0, 0, 0, 0),
89
- farClip: 10000,
90
  });
91
  app.root.addChild(camera);
92
 
93
- // Une lumière douce (utile en fallback / pré-AR)
94
  const light = new pc.Entity("Light");
95
  light.addComponent("light", {
96
  type: "spot",
97
  range: 30,
98
- intensity: 1.2,
99
- castShadows: false,
100
  });
101
  light.setLocalPosition(0, 10, 0);
102
  app.root.addChild(light);
103
 
104
- // Réticule (anneau) pour visualiser l’emplacement proposé par le hit-test
105
  const reticleMat = new pc.StandardMaterial();
106
  reticleMat.diffuse = new pc.Color(0.2, 0.8, 1.0);
107
  reticleMat.opacity = 0.85;
@@ -111,45 +187,44 @@
111
  const reticle = new pc.Entity("Reticle");
112
  reticle.addComponent("render", { type: "torus", material: reticleMat });
113
  reticle.setLocalScale(0.12, 0.005, 0.12);
114
- reticle.enabled = false; // activé quand AR démarre
115
  app.root.addChild(reticle);
116
 
117
- // Conteneur du modèle GLB
118
  const modelRoot = new pc.Entity("ModelRoot");
119
- modelRoot.enabled = false; // activé après chargement + 1er placement
120
  app.root.addChild(modelRoot);
121
 
122
  let modelLoaded = false;
123
  let placedOnce = false;
124
 
125
- // Chargement GLB (Container)
126
  app.assets.loadFromUrl(GLB_URL, "container", (err, asset) => {
127
  if (err) {
128
  console.error(err);
129
  message("Échec du chargement du modèle GLB.");
130
  return;
131
  }
132
- // Instancie le contenu GLB
133
  const instance = asset.resource.instantiateRenderEntity({
134
  castShadows: false,
135
- receiveShadows: false,
136
  });
137
  modelRoot.addChild(instance);
138
 
139
- // Mise à l’échelle raisonnable si le modèle est énorme/petit
140
- // (ajuste si besoin)
141
  modelRoot.setLocalScale(0.2, 0.2, 0.2);
142
 
143
  modelLoaded = true;
144
  message("Modèle chargé. Touchez l’écran pour démarrer l’AR.");
145
  });
146
 
147
- // ======== Gestion AR / Hit Test ========
148
  if (!app.xr.supported) {
149
  message("WebXR n’est pas supporté sur cet appareil.");
150
  return;
151
  }
152
 
 
153
  const activateAR = () => {
154
  if (!app.xr.isAvailable(pc.XRTYPE_AR)) {
155
  message("AR immersive indisponible sur cet appareil.");
@@ -157,14 +232,12 @@
157
  }
158
  camera.camera.startXr(pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, {
159
  callback: (err) => {
160
- if (err) {
161
- message(`Échec du démarrage AR : ${err.message}`);
162
- }
163
- },
164
  });
165
  };
166
 
167
- // Démarrer AR au premier tap/clic
168
  app.mouse.on("mousedown", () => {
169
  if (!app.xr.active) activateAR();
170
  });
@@ -176,12 +249,12 @@
176
  });
177
  }
178
 
179
- // ESC pour quitter
180
  app.keyboard.on("keydown", (evt) => {
181
  if (evt.key === pc.KEY_ESCAPE && app.xr.active) app.xr.end();
182
  });
183
 
184
- // Hit test global (pour le réticule et le premier placement auto)
185
  app.xr.hitTest.on("available", () => {
186
  app.xr.hitTest.start({
187
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
@@ -195,75 +268,64 @@
195
  reticle.setPosition(pos);
196
  reticle.setRotation(rot);
197
 
198
- // Premier placement auto du modèle d��s que possible
199
  if (modelLoaded && !placedOnce) {
200
  modelRoot.enabled = true;
201
  modelRoot.setPosition(pos);
202
- // Conserver uniquement la yaw (rotation Y) issue du plan
203
  const euler = new pc.Vec3();
204
  rot.getEulerAngles(euler);
205
  modelRoot.setEulerAngles(0, euler.y, 0);
206
  placedOnce = true;
207
- message("Objet placé. ⚠️ Glissez pour déplacer, pincez/2 doigts pour tourner.");
208
  }
209
  });
210
- },
211
  });
212
  });
213
 
214
- // État & helpers d’interaction
215
  let isDragging = false;
216
- let activeInputSource = null;
217
  let rotateMode = false;
218
- let lastAvgX = 0; // pour rotation à 2 doigts
219
- let lastMouseX = 0;
220
- const ROTATE_SENSITIVITY = 0.25; // degrés par pixel env.
221
 
222
- // Déplacement avec un input source XR (touch en AR)
223
  app.xr.input.on("add", (inputSource) => {
224
- // Sélection (tap et maintien)
225
  inputSource.on("selectstart", () => {
226
  if (!placedOnce || !modelLoaded) return;
227
 
228
- // Démarre un hit test TRANSITOIRE lié à la source pour suivre le doigt
229
  inputSource.hitTestStart({
230
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
231
  callback: (err, transientSource) => {
232
  if (err) return;
233
  isDragging = true;
234
- activeInputSource = { source: inputSource, ht: transientSource };
235
 
236
- transientSource.on("result", (pos, rot) => {
237
  if (!isDragging) return;
238
- // Déplacer sur le plan détecté
239
  modelRoot.setPosition(pos);
240
  });
241
 
242
  transientSource.once("remove", () => {
243
- if (activeInputSource && activeInputSource.ht === transientSource) {
244
- isDragging = false;
245
- activeInputSource = null;
246
- }
247
  });
248
- },
249
  });
250
  });
251
 
252
  inputSource.on("selectend", () => {
253
  isDragging = false;
254
- activeInputSource = null;
255
  });
256
  });
257
 
258
- // ======== Gestes Tactiles pour rotation (2 doigts) ========
259
  if (app.touch) {
260
  let touches = new Map();
261
-
262
  const getAvgX = () => {
263
  if (touches.size === 0) return 0;
264
- let sum = 0;
265
- touches.forEach((t) => (sum += t.x));
266
- return sum / touches.size;
267
  };
268
 
269
  app.touch.on("touchstart", (e) => {
@@ -282,7 +344,6 @@
282
  const avgX = getAvgX();
283
  const dx = avgX - lastAvgX;
284
  lastAvgX = avgX;
285
- // Appliquer rotation Y
286
  const euler = modelRoot.getEulerAngles();
287
  modelRoot.setEulerAngles(euler.x, euler.y + dx * ROTATE_SENSITIVITY, euler.z);
288
  }
@@ -299,12 +360,11 @@
299
  });
300
  }
301
 
302
- // ======== Souris (déplacement = clic gauche maintenu; rotation = clic droit ou Shift+clic) ========
303
  app.mouse.on("mousedown", (e) => {
304
  if (!app.xr.active || !placedOnce) return;
305
 
306
  if (e.button === 0 && !e.shiftKey) {
307
- // drag pour déplacement : on démarre un hit-test à partir du centre de l’écran à chaque move
308
  isDragging = true;
309
  } else if (e.button === 2 || (e.button === 0 && e.shiftKey)) {
310
  rotateMode = true;
@@ -315,10 +375,8 @@
315
  app.mouse.on("mousemove", (e) => {
316
  if (!app.xr.active || !placedOnce) return;
317
 
318
- if (isDragging && app.xr.hitTest.supported) {
319
- // Créer un hit test ponctuel lié au pointeur n’est pas direct via souris.
320
- // Astuce : on projette un point écran -> monde en AR via hit test global du reticle
321
- // Ici, on se sert du reticle déjà alimenté par le hit test continu pour repositionner rapidement.
322
  if (reticle.enabled) {
323
  const pos = reticle.getPosition();
324
  modelRoot.setPosition(pos);
@@ -336,10 +394,10 @@
336
  rotateMode = false;
337
  });
338
 
339
- // Empêcher le menu contextuel sur clic droit (utile pour la rotation)
340
  window.addEventListener("contextmenu", (e) => e.preventDefault());
341
 
342
- // ======== Événements AR globaux ========
343
  app.xr.on("start", () => {
344
  message("Session AR démarrée. Visez le sol pour détecter un plan…");
345
  reticle.enabled = true;
@@ -350,7 +408,6 @@
350
  reticle.enabled = false;
351
  isDragging = false;
352
  rotateMode = false;
353
- activeInputSource = null;
354
  });
355
 
356
  app.xr.on(`available:${pc.XRTYPE_AR}`, (available) => {
@@ -363,7 +420,7 @@
363
  }
364
  });
365
 
366
- // Messages initiaux
367
  if (!app.xr.isAvailable(pc.XRTYPE_AR)) {
368
  message("AR immersive indisponible.");
369
  } else if (!app.xr.hitTest.supported) {
 
1
+ /* script_ar.js — AR PlayCanvas + GLB HuggingFace
2
+ - Chargeur PlayCanvas robuste (ESM -> UMD avec fallbacks + timeout)
3
+ - WebXR AR Hit Test, placement auto, drag + rotation Y
4
+ - Aucune dépendance externe autre que PlayCanvas et le GLB
5
+ */
6
+
7
  (() => {
8
+ // =========================
9
+ // 1) Paramètres principaux
10
+ // =========================
11
  const GLB_URL = "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/AR/tests/fraisier.glb";
12
 
13
+ // =========================
14
+ // 2) Chargeur PlayCanvas robuste
15
+ // =========================
16
+ const PC_URLS = {
17
+ esm: [
18
+ "https://cdn.jsdelivr.net/npm/playcanvas@latest/build/playcanvas.min.mjs",
19
+ "https://cdn.jsdelivr.net/npm/playcanvas@2/build/playcanvas.min.mjs"
20
+ ],
21
+ umd: [
22
+ "https://cdnjs.cloudflare.com/ajax/libs/playcanvas/2.11.7/playcanvas.min.js",
23
+ "https://cdn.jsdelivr.net/npm/playcanvas@latest/build/playcanvas.min.js",
24
+ "https://cdn.jsdelivr.net/npm/playcanvas@2/build/playcanvas.min.js"
25
+ ]
26
  };
27
 
28
+ function timeout(ms) {
29
+ return new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms));
30
+ }
31
+
32
+ async function loadPlayCanvasRobust({ esmFirst = true, loadTimeoutMs = 15000 } = {}) {
33
+ if (window.pc && typeof window.pc.Application === "function") return window.pc;
34
+
35
+ const tryESM = async () => {
36
+ for (const url of PC_URLS.esm) {
37
+ try {
38
+ const mod = await Promise.race([import(/* @vite-ignore */ url), timeout(loadTimeoutMs)]);
39
+ const namespace = mod?.pc || mod?.default || mod;
40
+ if (namespace?.Application) {
41
+ if (!window.pc) window.pc = namespace;
42
+ return window.pc;
43
+ }
44
+ } catch (_) { /* continue */ }
45
+ }
46
+ throw new Error("ESM failed");
47
+ };
48
+
49
+ const tryUMD = async () => {
50
+ for (const url of PC_URLS.umd) {
51
+ try {
52
+ await Promise.race([
53
+ new Promise((res, rej) => {
54
+ const s = document.createElement("script");
55
+ s.src = url;
56
+ s.async = true;
57
+ s.onload = () => res();
58
+ s.onerror = () => rej(new Error("script error"));
59
+ document.head.appendChild(s);
60
+ }),
61
+ timeout(loadTimeoutMs)
62
+ ]);
63
+ if (window.pc && typeof window.pc.Application === "function") return window.pc;
64
+ } catch (_) { /* continue */ }
65
+ }
66
+ throw new Error("UMD failed");
67
+ };
68
+
69
+ if (esmFirst) {
70
+ try { return await tryESM(); } catch (_) { /* fallback */ }
71
+ return await tryUMD();
72
+ } else {
73
+ try { return await tryUMD(); } catch (_) { /* fallback */ }
74
+ return await tryESM();
75
+ }
76
+ }
77
+
78
+ // =========================
79
+ // 3) UI & utilitaires
80
+ // =========================
81
  const css = `
82
  .pc-ar-msg {
83
  position: fixed; left: 50%; transform: translateX(-50%);
 
85
  background: rgba(0,0,0,.65); color: #fff; border-radius: 8px;
86
  font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
87
  font-size: 14px; line-height: 1.3; text-align: center;
88
+ max-width: min(90vw, 640px);
89
  box-shadow: 0 6px 20px rgba(0,0,0,.25);
90
  backdrop-filter: blur(4px);
91
+ user-select: none;
92
+ pointer-events: none;
93
  }
94
  `;
95
  const styleTag = document.createElement("style");
96
  styleTag.textContent = css;
97
  document.head.appendChild(styleTag);
98
 
99
+ function message(msg) {
100
  let el = document.querySelector(".pc-ar-msg");
101
  if (!el) {
102
  el = document.createElement("div");
 
104
  document.body.appendChild(el);
105
  }
106
  el.textContent = msg;
107
+ }
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
+ function ensureCanvas() {
110
+ let canvas = document.getElementById("application-canvas");
111
+ if (!canvas) {
112
+ canvas = document.createElement("canvas");
113
+ canvas.id = "application-canvas";
114
+ // optionnel: force plein écran si aucun style
115
+ canvas.style.width = "100%";
116
+ canvas.style.height = "100%";
117
+ document.body.appendChild(canvas);
118
+ }
119
+ return canvas;
120
+ }
121
 
122
+ // =========================
123
+ // 4) Lancement
124
+ // =========================
125
+ (async () => {
126
+ try {
127
+ await loadPlayCanvasRobust({ esmFirst: true, loadTimeoutMs: 15000 });
128
+ } catch (e) {
129
+ console.error("Chargement PlayCanvas échoué ->", e);
130
+ message("Impossible de charger PlayCanvas (réseau/CDN). Réessaie plus tard.");
131
+ return;
132
+ }
133
+ // PlayCanvas dispo -> démarrer
134
+ initARApp();
135
+ })();
136
+
137
+ // =========================
138
+ // 5) App AR PlayCanvas
139
+ // =========================
140
+ function initARApp() {
141
+ const pc = window.pc; // alias
142
  const canvas = ensureCanvas();
143
  window.focus();
144
 
 
145
  const app = new pc.Application(canvas, {
146
  mouse: new pc.Mouse(canvas),
147
  touch: new pc.TouchDevice(canvas),
148
  keyboard: new pc.Keyboard(window),
149
+ graphicsDeviceOptions: { alpha: true }
150
  });
151
 
152
  app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
 
159
 
160
  app.start();
161
 
162
+ // --- Camera & light ---
163
  const camera = new pc.Entity("Camera");
164
  camera.addComponent("camera", {
165
  clearColor: new pc.Color(0, 0, 0, 0),
166
+ farClip: 10000
167
  });
168
  app.root.addChild(camera);
169
 
 
170
  const light = new pc.Entity("Light");
171
  light.addComponent("light", {
172
  type: "spot",
173
  range: 30,
174
+ intensity: 1.1,
175
+ castShadows: false
176
  });
177
  light.setLocalPosition(0, 10, 0);
178
  app.root.addChild(light);
179
 
180
+ // --- Reticle (anneau) ---
181
  const reticleMat = new pc.StandardMaterial();
182
  reticleMat.diffuse = new pc.Color(0.2, 0.8, 1.0);
183
  reticleMat.opacity = 0.85;
 
187
  const reticle = new pc.Entity("Reticle");
188
  reticle.addComponent("render", { type: "torus", material: reticleMat });
189
  reticle.setLocalScale(0.12, 0.005, 0.12);
190
+ reticle.enabled = false;
191
  app.root.addChild(reticle);
192
 
193
+ // --- Conteneur modèle GLB ---
194
  const modelRoot = new pc.Entity("ModelRoot");
195
+ modelRoot.enabled = false;
196
  app.root.addChild(modelRoot);
197
 
198
  let modelLoaded = false;
199
  let placedOnce = false;
200
 
201
+ // --- Chargement GLB ---
202
  app.assets.loadFromUrl(GLB_URL, "container", (err, asset) => {
203
  if (err) {
204
  console.error(err);
205
  message("Échec du chargement du modèle GLB.");
206
  return;
207
  }
 
208
  const instance = asset.resource.instantiateRenderEntity({
209
  castShadows: false,
210
+ receiveShadows: false
211
  });
212
  modelRoot.addChild(instance);
213
 
214
+ // Ajuste si besoin selon ton modèle
 
215
  modelRoot.setLocalScale(0.2, 0.2, 0.2);
216
 
217
  modelLoaded = true;
218
  message("Modèle chargé. Touchez l’écran pour démarrer l’AR.");
219
  });
220
 
221
+ // --- Vérifs WebXR ---
222
  if (!app.xr.supported) {
223
  message("WebXR n’est pas supporté sur cet appareil.");
224
  return;
225
  }
226
 
227
+ // --- Démarrage AR ---
228
  const activateAR = () => {
229
  if (!app.xr.isAvailable(pc.XRTYPE_AR)) {
230
  message("AR immersive indisponible sur cet appareil.");
 
232
  }
233
  camera.camera.startXr(pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, {
234
  callback: (err) => {
235
+ if (err) message(`Échec du démarrage AR : ${err.message}`);
236
+ }
 
 
237
  });
238
  };
239
 
240
+ // Premier tap / clic → démarrer AR
241
  app.mouse.on("mousedown", () => {
242
  if (!app.xr.active) activateAR();
243
  });
 
249
  });
250
  }
251
 
252
+ // ESC quitter
253
  app.keyboard.on("keydown", (evt) => {
254
  if (evt.key === pc.KEY_ESCAPE && app.xr.active) app.xr.end();
255
  });
256
 
257
+ // --- Hit Test global (réglage réticule + 1er placement) ---
258
  app.xr.hitTest.on("available", () => {
259
  app.xr.hitTest.start({
260
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
 
268
  reticle.setPosition(pos);
269
  reticle.setRotation(rot);
270
 
 
271
  if (modelLoaded && !placedOnce) {
272
  modelRoot.enabled = true;
273
  modelRoot.setPosition(pos);
274
+ // Conserver uniquement la yaw (rotation Y)
275
  const euler = new pc.Vec3();
276
  rot.getEulerAngles(euler);
277
  modelRoot.setEulerAngles(0, euler.y, 0);
278
  placedOnce = true;
279
+ message("Objet placé. Glissez pour déplacer, 2 doigts / clic droit pour tourner.");
280
  }
281
  });
282
+ }
283
  });
284
  });
285
 
286
+ // --- Interactions ---
287
  let isDragging = false;
 
288
  let rotateMode = false;
289
+ let lastAvgX = 0; // rotation tactile (2 doigts)
290
+ let lastMouseX = 0; // rotation souris
291
+ const ROTATE_SENSITIVITY = 0.25; // degrés / pixel approx.
292
 
293
+ // Déplacement (input XR : maintien/drag)
294
  app.xr.input.on("add", (inputSource) => {
 
295
  inputSource.on("selectstart", () => {
296
  if (!placedOnce || !modelLoaded) return;
297
 
 
298
  inputSource.hitTestStart({
299
  entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE],
300
  callback: (err, transientSource) => {
301
  if (err) return;
302
  isDragging = true;
 
303
 
304
+ transientSource.on("result", (pos /*, rot */) => {
305
  if (!isDragging) return;
 
306
  modelRoot.setPosition(pos);
307
  });
308
 
309
  transientSource.once("remove", () => {
310
+ isDragging = false;
 
 
 
311
  });
312
+ }
313
  });
314
  });
315
 
316
  inputSource.on("selectend", () => {
317
  isDragging = false;
 
318
  });
319
  });
320
 
321
+ // Rotation tactile (2 doigts glisser horizontal)
322
  if (app.touch) {
323
  let touches = new Map();
 
324
  const getAvgX = () => {
325
  if (touches.size === 0) return 0;
326
+ let s = 0;
327
+ touches.forEach((t) => (s += t.x));
328
+ return s / touches.size;
329
  };
330
 
331
  app.touch.on("touchstart", (e) => {
 
344
  const avgX = getAvgX();
345
  const dx = avgX - lastAvgX;
346
  lastAvgX = avgX;
 
347
  const euler = modelRoot.getEulerAngles();
348
  modelRoot.setEulerAngles(euler.x, euler.y + dx * ROTATE_SENSITIVITY, euler.z);
349
  }
 
360
  });
361
  }
362
 
363
+ // Souris : déplacement (clic gauche maintenu) & rotation (clic droit ou Shift+clic gauche)
364
  app.mouse.on("mousedown", (e) => {
365
  if (!app.xr.active || !placedOnce) return;
366
 
367
  if (e.button === 0 && !e.shiftKey) {
 
368
  isDragging = true;
369
  } else if (e.button === 2 || (e.button === 0 && e.shiftKey)) {
370
  rotateMode = true;
 
375
  app.mouse.on("mousemove", (e) => {
376
  if (!app.xr.active || !placedOnce) return;
377
 
378
+ if (isDragging) {
379
+ // On s’aligne sur le réticule (alimenté en continu par le hit test)
 
 
380
  if (reticle.enabled) {
381
  const pos = reticle.getPosition();
382
  modelRoot.setPosition(pos);
 
394
  rotateMode = false;
395
  });
396
 
397
+ // Empêcher menu contextuel (utile pour la rotation au clic droit)
398
  window.addEventListener("contextmenu", (e) => e.preventDefault());
399
 
400
+ // --- Événements AR globaux ---
401
  app.xr.on("start", () => {
402
  message("Session AR démarrée. Visez le sol pour détecter un plan…");
403
  reticle.enabled = true;
 
408
  reticle.enabled = false;
409
  isDragging = false;
410
  rotateMode = false;
 
411
  });
412
 
413
  app.xr.on(`available:${pc.XRTYPE_AR}`, (available) => {
 
420
  }
421
  });
422
 
423
+ // Message initial
424
  if (!app.xr.isAvailable(pc.XRTYPE_AR)) {
425
  message("AR immersive indisponible.");
426
  } else if (!app.xr.hitTest.supported) {