Download viewer_ar_ios.js from MikaFil/VR: direct link, hf CLI and curl.
- Browser
- Download file 32.1 kB
-
https://huggingface.co/spaces/MikaFil/VR/resolve/main/viewer_ar_ios.js
- Command line
-
hf download hf://spaces/MikaFil/VR/viewer_ar_ios.js
-
curl -L -o viewer_ar_ios.js https://huggingface.co/spaces/MikaFil/VR/resolve/main/viewer_ar_ios.js
32.1 kB
| /* viewer_ar_ios.js — AR PlayCanvas + GLB/USDZ via config.json (+ "sol") | |
| - config.json : { "glb_url": "...", "usdz_url": "...", "sol": true|false } | |
| - sol = true → plans horizontaux (sol) | |
| * Y local = Up monde | |
| * rotation utilisateur = autour de Y local | |
| - sol = false → plans verticaux (mur) | |
| * Y local = normale du mur | |
| * Z local = Up monde projeté dans le plan du mur | |
| * rotation utilisateur = autour de Y local (normale) | |
| - Translation : aucune rotation pendant le drag ; snap sur nouveau plan en fin de drag | |
| - iOS : AR Quick Look (manuel) | |
| - Android/Desktop : WebXR + PlayCanvas | |
| */ | |
| (function () { | |
| // ========================== | |
| // Utilitaires généraux | |
| // ========================== | |
| function getCurrentScript() { | |
| return document.currentScript || (function () { | |
| var scripts = document.getElementsByTagName('script'); | |
| return scripts[scripts.length - 1] || null; | |
| })(); | |
| } | |
| function findConfigUrl() { | |
| var el = getCurrentScript(); | |
| if (!el) return null; | |
| var url = el.getAttribute('data-config'); | |
| return url || null; | |
| } | |
| function isIOS() { | |
| return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; | |
| } | |
| function timeout(ms) { | |
| return new Promise(function (_res, rej) { | |
| setTimeout(function () { rej(new Error("timeout")); }, ms); | |
| }); | |
| } | |
| async function loadConfigJson(url) { | |
| if (!url) return null; | |
| try { | |
| var resp = await fetch(url, { cache: 'no-store' }); | |
| if (!resp.ok) throw new Error("HTTP " + resp.status); | |
| var json = await resp.json(); | |
| return json; | |
| } catch (e) { | |
| console.error("Erreur chargement config.json:", e); | |
| return null; | |
| } | |
| } | |
| // ========================== | |
| // PlayCanvas (Android/Desktop) | |
| // ========================== | |
| var PC_VERSION = "2.11.7"; | |
| var PC_URLS = { | |
| esm: ["https://cdn.jsdelivr.net/npm/playcanvas@" + PC_VERSION + "/build/playcanvas.min.mjs"], | |
| umd: ["https://cdn.jsdelivr.net/npm/playcanvas@" + PC_VERSION + "/build/playcanvas.min.js"] | |
| }; | |
| async function loadPlayCanvasRobust(opts) { | |
| opts = opts || {}; | |
| var esmFirst = (typeof opts.esmFirst === "boolean") ? opts.esmFirst : true; | |
| var loadTimeoutMs = (typeof opts.loadTimeoutMs === "number") ? opts.loadTimeoutMs : 15000; | |
| if (window.pc && window.pc.Application) return window.pc; | |
| async function tryESM() { | |
| for (var i = 0; i < PC_URLS.esm.length; i++) { | |
| var url = PC_URLS.esm[i]; | |
| try { | |
| var mod = await Promise.race([import(/* @vite-ignore */ url), timeout(loadTimeoutMs)]); | |
| var ns = (mod && (mod.pc || mod["default"])) || mod; | |
| if (ns && ns.Application) { | |
| if (!window.pc) window.pc = ns; | |
| return window.pc; | |
| } | |
| } catch (e) { /* continue */ } | |
| } | |
| throw new Error("ESM failed"); | |
| } | |
| async function tryUMD() { | |
| for (var j = 0; j < PC_URLS.umd.length; j++) { | |
| var url2 = PC_URLS.umd[j]; | |
| try { | |
| await Promise.race([ | |
| new Promise(function (res, rej) { | |
| var s = document.createElement("script"); | |
| s.src = url2; | |
| s.async = true; | |
| s.onload = function () { res(); }; | |
| s.onerror = function () { rej(new Error("script error")); }; | |
| document.head.appendChild(s); | |
| }), | |
| timeout(loadTimeoutMs) | |
| ]); | |
| if (window.pc && window.pc.Application) return window.pc; | |
| } catch (e) { /* continue */ } | |
| } | |
| throw new Error("UMD failed"); | |
| } | |
| try { | |
| if (esmFirst) return await tryESM(); | |
| return await tryUMD(); | |
| } catch (e) { | |
| if (esmFirst) return await tryUMD(); | |
| return await tryESM(); | |
| } | |
| } | |
| // ========================== | |
| // Overlay / UI (styles intégrés) | |
| // ========================== | |
| var css = [ | |
| "#ar-overlay-root{position:fixed;inset:0;z-index:10001;pointer-events:none}", | |
| ".pc-ar-msg{position:fixed;left:50%;transform:translateX(-50%);bottom:16px;z-index:10002;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}", | |
| ".pc-ar-msg.pc-ar-msg--centerBig{top:50%;bottom:auto;transform:translate(-50%,-50%);font-size:20px;padding:16px 22px;max-width:min(90vw,760px)}", | |
| "#ar-start-button{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10003;pointer-events:auto;border:none;border-radius:16px;padding:16px 24px;font-size:18px;line-height:1;background:#000;color:#fff;box-shadow:0 10px 28px rgba(0,0,0,.28);cursor:pointer;min-width:200px;text-align:center}", | |
| "#ar-start-button[disabled]{opacity:.5;cursor:not-allowed}", | |
| "#ar-ios-quicklook-button{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10003;pointer-events:auto;display:inline-block;text-decoration:none}", | |
| "#ar-ios-quicklook-button img{display:block;height:60px;width:auto;box-shadow:0 10px 28px rgba(0,0,0,.28);border-radius:14px}", | |
| ".ar-rotation-panel{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}", | |
| ".ar-rotation-panel .label{font-size:12px;text-align:center;opacity:.95}", | |
| "#ar-rotation-track{position:relative;width:48px;height:200px;display:flex;align-items:center;justify-content:center;touch-action:none;overflow:visible;pointer-events:auto}", | |
| ".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}", | |
| "#ar-rotation-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}", | |
| "input#ar-rotation-range.rotY{position:absolute;opacity:0;pointer-events:none;width:0;height:0}", | |
| "#ar-rotation-value{font-size:12px;opacity:.95}" | |
| ].join("\n"); | |
| var styleTag = document.createElement("style"); | |
| styleTag.textContent = css; | |
| document.head.appendChild(styleTag); | |
| function ensureOverlayRoot() { | |
| var r = document.getElementById("ar-overlay-root"); | |
| if (!r) { | |
| r = document.createElement("div"); | |
| r.id = "ar-overlay-root"; | |
| document.body.appendChild(r); | |
| } | |
| return r; | |
| } | |
| var overlayRoot = ensureOverlayRoot(); | |
| function message(txt) { | |
| var el = overlayRoot.querySelector(".pc-ar-msg"); | |
| if (!el) { | |
| el = document.createElement("div"); | |
| el.className = "pc-ar-msg"; | |
| overlayRoot.appendChild(el); | |
| } | |
| var isStartMsg = /Appuyez sur .Lancer l.?AR./i.test(txt); | |
| el.classList.toggle("pc-ar-msg--centerBig", isStartMsg); | |
| el.textContent = txt; | |
| } | |
| // iOS Quick Look URL sans zoom | |
| function buildQuickLookHref(usdzUrl) { | |
| try { | |
| var u = new URL(usdzUrl, window.location.href); | |
| var hash = u.hash ? u.hash.replace(/^#/, '') : ''; | |
| var params = new URLSearchParams(hash); | |
| params.set('allowsContentScaling', '0'); | |
| u.hash = params.toString(); | |
| return u.toString(); | |
| } catch (e) { | |
| return usdzUrl + (usdzUrl.indexOf('#') >= 0 ? '&' : '#') + 'allowsContentScaling=0'; | |
| } | |
| } | |
| // iOS : bouton Quick Look | |
| function ensureQuickLookButton(USDZ_URL) { | |
| var btn = document.getElementById("ar-ios-quicklook-button"); | |
| if (btn) return btn; | |
| var anchor = document.createElement("a"); | |
| anchor.id = "ar-ios-quicklook-button"; | |
| anchor.setAttribute("rel", "ar"); | |
| anchor.setAttribute("href", buildQuickLookHref(USDZ_URL)); | |
| var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="240" height="60">' + | |
| '<rect rx="14" ry="14" width="240" height="60" fill="black"/>' + | |
| '<text x="120" y="37" font-size="18" text-anchor="middle" fill="white" font-family="system-ui, -apple-system, Segoe UI, Roboto">Lancer l’AR</text>' + | |
| '</svg>'; | |
| var img = document.createElement("img"); | |
| img.alt = "Lancer l’AR"; | |
| img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg); | |
| anchor.appendChild(img); | |
| document.body.appendChild(anchor); | |
| return anchor; | |
| } | |
| // Android/Desktop : bouton “Lancer l’AR” | |
| function ensureARStartButton(onClick) { | |
| var btn = document.getElementById("ar-start-button"); | |
| if (!btn) { | |
| btn = document.createElement("button"); | |
| btn.id = "ar-start-button"; | |
| btn.type = "button"; | |
| btn.textContent = "Lancer l’AR"; | |
| btn.addEventListener("click", function (e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (typeof onClick === "function") onClick(); | |
| }); | |
| document.body.appendChild(btn); | |
| } | |
| return btn; | |
| } | |
| // ========================== | |
| // Montage du canvas | |
| // ========================== | |
| function ensureCanvas() { | |
| var scriptEl = getCurrentScript(); | |
| var mountSel = scriptEl && scriptEl.getAttribute('data-mount'); | |
| var desiredHeight = (scriptEl && scriptEl.getAttribute('data-height')) || '70vh'; | |
| var mountEl = null; | |
| if (mountSel) mountEl = document.querySelector(mountSel); | |
| if (!mountEl) { | |
| mountEl = document.createElement('div'); | |
| mountEl.id = 'ar-mount-fallback'; | |
| document.body.insertBefore(mountEl, document.body.firstChild); | |
| } | |
| var mountStyle = mountEl.style; | |
| if (!mountStyle.position) mountStyle.position = 'relative'; | |
| mountStyle.width = mountStyle.width || '100%'; | |
| mountStyle.minHeight = mountStyle.minHeight || desiredHeight; | |
| mountStyle.touchAction = mountStyle.touchAction || 'manipulation'; | |
| mountStyle.webkitTapHighlightColor = 'transparent'; | |
| var canvas = mountEl.querySelector('#application-canvas'); | |
| if (!canvas) { | |
| canvas = document.createElement('canvas'); | |
| canvas.id = 'application-canvas'; | |
| mountEl.appendChild(canvas); | |
| } | |
| var cs = canvas.style; | |
| cs.position = 'absolute'; | |
| cs.left = '0'; | |
| cs.top = '0'; | |
| cs.width = '100%'; | |
| cs.height = '100%'; | |
| cs.display = 'block'; | |
| try { mountEl.scrollIntoView({ behavior: 'instant', block: 'start' }); } catch (_) {} | |
| return canvas; | |
| } | |
| function ensureRotationPanel() { | |
| var p = overlayRoot.querySelector(".ar-rotation-panel"); | |
| if (p) return p; | |
| p = document.createElement("div"); | |
| p.className = "ar-rotation-panel"; | |
| p.innerHTML = | |
| '<div class="label">Rotation</div>' + | |
| '<div id="ar-rotation-track" class="rotY-wrap">' + | |
| ' <div class="rotY-rail"></div>' + | |
| ' <div id="ar-rotation-knob" class="rotY-knob"></div>' + | |
| ' <input id="ar-rotation-range" class="rotY" type="range" min="0" max="360" step="1" value="0"/>' + | |
| '</div>' + | |
| '<div id="ar-rotation-value" class="val">0°</div>'; | |
| overlayRoot.appendChild(p); | |
| return p; | |
| } | |
| // ========================== | |
| // Démarrage (config → iOS vs Android/Desktop) | |
| // ========================== | |
| (async function () { | |
| var cfgUrl = findConfigUrl(); | |
| var cfg = await loadConfigJson(cfgUrl); | |
| var GLB_URL = (cfg && typeof cfg.glb_url === "string" && cfg.glb_url) ? | |
| cfg.glb_url : | |
| "https://huggingface.co/datasets/MikaFil/viewer_gs/resolve/main/AR/tests/danae_no_metallic.glb"; | |
| var USDZ_URL = (cfg && typeof cfg.usdz_url === "string" && cfg.usdz_url) ? | |
| cfg.usdz_url : | |
| null; | |
| // lecture de "sol" (par défaut true) | |
| var PLACE_ON_FLOOR = true; | |
| if (cfg && typeof cfg.sol === "boolean") PLACE_ON_FLOOR = !!cfg.sol; | |
| // iOS → Quick Look | |
| if (isIOS()) { | |
| if (USDZ_URL) { | |
| ensureQuickLookButton(USDZ_URL); | |
| if (PLACE_ON_FLOOR) { | |
| message("Modèle chargé. Appuyez sur « Lancer l’AR ». Dans Quick Look, placez l’objet sur le sol et ajustez-le avec les gestes iOS."); | |
| } else { | |
| message("Modèle chargé. Appuyez sur « Lancer l’AR ». Sur iOS l’alignement au mur n’est pas disponible. "+ | |
| "Glissez deux doigts pour les déplacements haut/bas. Glissez un doigts pour les déplacements avant/arrière/gauche/droite"); | |
| } | |
| } else { | |
| message("iOS détecté, mais aucun 'usdz_url' dans config.json."); | |
| } | |
| return; | |
| } | |
| // Android/Desktop → WebXR | |
| 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(GLB_URL, PLACE_ON_FLOOR); | |
| })(); | |
| // ========================== | |
| // Application WebXR (Android/Desktop) | |
| // ========================== | |
| function initARApp(GLB_URL, PLACE_ON_FLOOR) { | |
| var pc = window.pc; | |
| var canvas = ensureCanvas(); | |
| var rotationPanel = ensureRotationPanel(); | |
| var rotTrack = rotationPanel.querySelector("#ar-rotation-track"); | |
| var rotKnob = rotationPanel.querySelector("#ar-rotation-knob"); | |
| var rotInput = rotationPanel.querySelector("#ar-rotation-range"); | |
| var rotVal = rotationPanel.querySelector("#ar-rotation-value"); | |
| window.focus(); | |
| var app = new pc.Application(canvas, { | |
| mouse: new pc.Mouse(canvas), | |
| touch: new pc.TouchDevice(canvas), | |
| keyboard: new pc.Keyboard(window), | |
| graphicsDeviceOptions: { alpha: true } | |
| }); | |
| app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW); | |
| app.setCanvasResolution(pc.RESOLUTION_AUTO); | |
| app.graphicsDevice.maxPixelRatio = window.devicePixelRatio || 1; | |
| var onResize = function () { app.resizeCanvas(); }; | |
| window.addEventListener("resize", onResize); | |
| app.on("destroy", function () { window.removeEventListener("resize", onResize); }); | |
| app.start(); | |
| // Rendu PBR “safe” | |
| app.scene.gammaCorrection = pc.GAMMA_SRGB; | |
| app.scene.toneMapping = pc.TONEMAP_ACES; | |
| app.scene.exposure = 1; | |
| app.scene.ambientLight = new pc.Color(1, 1, 1); | |
| // Caméra + lumière | |
| var camera = new pc.Entity("Camera"); | |
| camera.addComponent("camera", { clearColor: new pc.Color(0, 0, 0, 0), farClip: 10000 }); | |
| app.root.addChild(camera); | |
| var light = new pc.Entity("Light"); | |
| light.addComponent("light", { type: "directional", intensity: 1.0, castShadows: true, color: new pc.Color(1, 1, 1) }); | |
| light.setLocalEulerAngles(45, 30, 0); | |
| app.root.addChild(light); | |
| // Réticule | |
| var 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(); | |
| var 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); | |
| // Modèle | |
| var modelRoot = new pc.Entity("ModelRoot"); | |
| modelRoot.enabled = false; | |
| app.root.addChild(modelRoot); | |
| var modelLoaded = false, placedOnce = false; | |
| // --- Helpers vecteurs/quaternions --- | |
| var UP = new pc.Vec3(0, 1, 0); | |
| function projOnPlane(v, n) { | |
| var d = v.dot(n); | |
| return new pc.Vec3(v.x - d*n.x, v.y - d*n.y, v.z - d*n.z); | |
| } | |
| function quatFromBasis(X, Y, Z) { | |
| // colonnes = X,Y,Z | |
| var m00 = X.x, m01 = Y.x, m02 = Z.x; | |
| var m10 = X.y, m11 = Y.y, m12 = Z.y; | |
| var m20 = X.z, m21 = Y.z, m22 = Z.z; | |
| var t = m00 + m11 + m22; | |
| var q = new pc.Quat(); | |
| if (t > 0) { | |
| var s = Math.sqrt(t + 1.0) * 2; | |
| q.w = 0.25 * s; | |
| q.x = (m21 - m12) / s; | |
| q.y = (m02 - m20) / s; | |
| q.z = (m10 - m01) / s; | |
| } else if (m00 > m11 && m00 > m22) { | |
| var s = Math.sqrt(1.0 + m00 - m11 - m22) * 2; | |
| q.w = (m21 - m12) / s; | |
| q.x = 0.25 * s; | |
| q.y = (m01 + m10) / s; | |
| q.z = (m02 + m20) / s; | |
| } else if (m11 > m22) { | |
| var s2 = Math.sqrt(1.0 + m11 - m00 - m22) * 2; | |
| q.w = (m02 - m20) / s2; | |
| q.x = (m01 + m10) / s2; | |
| q.y = 0.25 * s2; | |
| q.z = (m12 + m21) / s2; | |
| } else { | |
| var s3 = Math.sqrt(1.0 + m22 - m00 - m11) * 2; | |
| q.w = (m10 - m01) / s3; | |
| q.x = (m02 + m20) / s3; | |
| q.y = (m12 + m21) / s3; | |
| q.z = 0.25 * s3; | |
| } | |
| q.normalize(); | |
| return q; | |
| } | |
| // --- Bases pour chaque mode --- | |
| function computeFloorBase() { | |
| // Y local = UP (0,1,0). On fabrique une base quelconque stable. | |
| var Y = UP.clone(); // vertical | |
| var Z = new pc.Vec3(0, 0, 1); // avant “monde” | |
| // Orthonormalise Z dans le plan horizontal | |
| Z = projOnPlane(Z, Y); if (Z.lengthSq() < 1e-8) Z = new pc.Vec3(1,0,0); | |
| Z.normalize(); | |
| var X = new pc.Vec3(); X.cross(Y, Z).normalize(); | |
| Z.cross(X, Y).normalize(); | |
| return quatFromBasis(X, Y, Z); | |
| } | |
| function computeWallBaseFromHit(rot) { | |
| // Hypothèse PlayCanvas/WebXR : la normale du plan est l’axe Y de la pose. | |
| // N = rot * (0,1,0) | |
| var N = new pc.Vec3(); | |
| rot.transformVector(new pc.Vec3(0,1,0), N); | |
| N.normalize(); // Y local = N (normale du mur) | |
| // Z local = Up projeté dans le plan (toujours “vers le plafond” dans le plan vertical) | |
| var Z = projOnPlane(UP, N); | |
| if (Z.lengthSq() < 1e-8) Z = new pc.Vec3(0,0,1); | |
| Z.normalize(); | |
| // X = Y × Z ; re-orthonormalise | |
| var X = new pc.Vec3(); X.cross(N, Z).normalize(); | |
| Z.cross(X, N).normalize(); | |
| return quatFromBasis(X, N /*Y*/, Z); | |
| } | |
| // Mode mur / sol | |
| var wallMode = !PLACE_ON_FLOOR; | |
| var baseRot = new pc.Quat(); // base : définit les axes locaux comme voulu (voir ci-dessus) | |
| var userYawDeg = 0; // rotation utilisateur autour de Y local (toujours) | |
| // Ombre blob (sol uniquement) | |
| var blob = null; | |
| var BLOB_SIZE = 0.4; | |
| var BLOB_OFFSET_Y = 0.005; | |
| function makeBlobTexture(app, size) { | |
| size = size || 256; | |
| var cvs = document.createElement('canvas'); | |
| cvs.width = cvs.height = size; | |
| var ctx = cvs.getContext('2d'); | |
| var r = size * 0.45; | |
| var grd = ctx.createRadialGradient(size/2, size/2, r*0.2, size/2, size/2, r); | |
| grd.addColorStop(0, 'rgba(0,0,0,0.5)'); | |
| grd.addColorStop(1, 'rgba(0,0,0,0.0)'); | |
| ctx.fillStyle = grd; | |
| ctx.fillRect(0, 0, size, size); | |
| var tex = new pc.Texture(app.graphicsDevice, { | |
| width: size, | |
| height: size, | |
| format: pc.PIXELFORMAT_R8_G8_B8_A8, | |
| mipmaps: true, | |
| magFilter: pc.FILTER_LINEAR, | |
| minFilter: pc.FILTER_LINEAR_MIPMAP_LINEAR | |
| }); | |
| tex.setSource(cvs); | |
| return tex; | |
| } | |
| function createBlobShadowAt(pos, rot) { | |
| var tex = makeBlobTexture(app, 256); | |
| var blobMat = new pc.StandardMaterial(); | |
| blobMat.diffuse = new pc.Color(0, 0, 0); | |
| blobMat.opacity = 1.0; | |
| blobMat.opacityMap = tex; | |
| blobMat.opacityMapChannel = 'a'; | |
| blobMat.useLighting = false; | |
| blobMat.blendType = pc.BLEND_NORMAL; | |
| blobMat.depthWrite = false; | |
| blobMat.alphaTest = 0; | |
| blobMat.update(); | |
| var e = new pc.Entity("BlobShadow"); | |
| e.addComponent("render", { type: "plane", castShadows: false, receiveShadows: false }); | |
| e.render.material = blobMat; | |
| e.setPosition(pos.x, pos.y + BLOB_OFFSET_Y, pos.z); | |
| e.setRotation(rot); | |
| e.setLocalScale(BLOB_SIZE, 1, BLOB_SIZE); | |
| app.root.addChild(e); | |
| return e; | |
| } | |
| // Rotation via slider (toujours autour de Y local) | |
| function clamp360(d) { return Math.max(0, Math.min(360, d)); } | |
| function updateKnobFromY(yDeg) { | |
| var t = 1 - (yDeg / 360); | |
| rotKnob.style.top = String(t * 100) + "%"; | |
| rotInput.value = String(Math.round(yDeg)); | |
| rotVal.textContent = String(Math.round(yDeg)) + "°"; | |
| } | |
| function applyYawAroundLocalY(deg) { | |
| var y = clamp360(deg); | |
| userYawDeg = y; | |
| if (!modelRoot.enabled) { updateKnobFromY(y); return; } | |
| var qLocal = new pc.Quat(); | |
| qLocal.setFromAxisAngle(pc.Vec3.UP, y); // UP ici = axe Y LOCAL quand on post-multiplie | |
| var qFinal = new pc.Quat(); | |
| qFinal.mul2(baseRot, qLocal); | |
| modelRoot.setRotation(qFinal); | |
| updateKnobFromY(y); | |
| } | |
| function updateBlobPositionUnder(pos, rotLikePlane) { | |
| if (!blob) return; | |
| blob.setPosition(pos.x, pos.y + BLOB_OFFSET_Y, pos.z); | |
| if (rotLikePlane) blob.setRotation(rotLikePlane); | |
| } | |
| // Chargement GLB | |
| app.assets.loadFromUrl(GLB_URL, "container", function (err, asset) { | |
| if (err) { console.error(err); message("Échec du chargement du modèle GLB."); return; } | |
| var instance = asset.resource.instantiateRenderEntity({ castShadows: true, receiveShadows: false }); | |
| modelRoot.addChild(instance); | |
| modelRoot.setLocalScale(1, 1, 1); | |
| // matériaux safe | |
| var renders = instance.findComponents('render'); | |
| for (var ri = 0; ri < renders.length; ri++) { | |
| var r = renders[ri]; | |
| r.castShadows = true; | |
| for (var mi = 0; mi < r.meshInstances.length; mi++) { | |
| var mat = r.meshInstances[mi].material; | |
| if (!mat) continue; | |
| if (mat.diffuse && (mat.diffuse.r !== 1 || mat.diffuse.g !== 1 || mat.diffuse.b !== 1)) { | |
| mat.diffuse.set(1, 1, 1); | |
| } | |
| if (typeof mat.useSkybox !== "undefined") mat.useSkybox = true; | |
| mat.update(); | |
| } | |
| } | |
| modelLoaded = true; | |
| message("Modèle chargé. Appuyez sur « Lancer l’AR »."); | |
| }); | |
| if (!app.xr.supported) { message("WebXR n’est pas supporté sur cet appareil."); return; } | |
| // ----- Détection orientation des plans (pose Y = normale du plan) ----- | |
| var TMP_Y = new pc.Vec3(0, 1, 0), TMP_OUT = new pc.Vec3(); | |
| // Sol : normale (Y) ≈ +Up | |
| function isHorizontalUpFacing(rot, minDot) { | |
| minDot = (typeof minDot === "number") ? minDot : 0.75; | |
| rot.transformVector(TMP_Y, TMP_OUT); | |
| return TMP_OUT.y >= minDot; | |
| } | |
| // Mur : normale (Y) horizontale → |Y.y| petit | |
| function isVerticalFacing(rot, maxAbsY) { | |
| maxAbsY = (typeof maxAbsY === "number") ? maxAbsY : 0.35; | |
| rot.transformVector(TMP_Y, TMP_OUT); | |
| return Math.abs(TMP_OUT.y) <= maxAbsY; | |
| } | |
| function planeMatchesMode(rot) { | |
| return wallMode ? isVerticalFacing(rot) : isHorizontalUpFacing(rot); | |
| } | |
| // --- Translation sans rotation parasite --- | |
| var isDragging = false; | |
| var dragLockedRot = new pc.Quat(); // orientation figée pendant le drag | |
| var lastHitRot = new pc.Quat(); // dernière rotation de hit (pour snap en fin de drag) | |
| // Slider (pointer capture UI) | |
| var uiInteracting = false; | |
| var draggingTrack = false; | |
| var activePointerId = null; | |
| function insideTrack(target) { return rotTrack.contains(target); } | |
| function degFromPointer(e) { | |
| var rect = rotTrack.getBoundingClientRect(); | |
| var y = (e.clientY != null) ? e.clientY : ((e.touches && e.touches[0] && e.touches[0].clientY) || 0); | |
| var ratio = (y - rect.top) / rect.height; | |
| var t = Math.max(0, Math.min(1, ratio)); | |
| return (1 - t) * 360; | |
| } | |
| function onPointerDownCapture(e) { | |
| if (!insideTrack(e.target)) return; | |
| uiInteracting = true; | |
| draggingTrack = true; | |
| activePointerId = (e.pointerId != null) ? e.pointerId : 1; | |
| if (rotTrack.setPointerCapture) { try { rotTrack.setPointerCapture(activePointerId); } catch (er) {} } | |
| applyYawAroundLocalY(degFromPointer(e)); | |
| e.preventDefault(); e.stopPropagation(); | |
| } | |
| function onPointerMoveCapture(e) { | |
| if (!draggingTrack || ((e.pointerId != null ? e.pointerId : 1) !== activePointerId)) return; | |
| applyYawAroundLocalY(degFromPointer(e)); | |
| e.preventDefault(); e.stopPropagation(); | |
| } | |
| function endDrag(e) { | |
| if (!draggingTrack || ((e.pointerId != null ? e.pointerId : 1) !== activePointerId)) return; | |
| draggingTrack = false; uiInteracting = false; | |
| if (rotTrack.releasePointerCapture) { try { rotTrack.releasePointerCapture(activePointerId); } catch (er) {} } | |
| activePointerId = null; | |
| e.preventDefault(); e.stopPropagation(); | |
| } | |
| document.addEventListener("pointerdown", onPointerDownCapture, { capture: true, passive: false }); | |
| document.addEventListener("pointermove", onPointerMoveCapture, { capture: true, passive: false }); | |
| document.addEventListener("pointerup", endDrag, { capture: true, passive: false }); | |
| document.addEventListener("pointercancel", endDrag, { capture: true, passive: false }); | |
| // Démarrage AR (UNIQUEMENT via bouton) | |
| function activateAR() { | |
| if (!app.xr.isAvailable(pc.XRTYPE_AR)) { message("AR immersive indisponible sur cet appareil."); return; } | |
| if (!app.xr.domOverlay) app.xr.domOverlay = {}; | |
| app.xr.domOverlay.root = document.getElementById("ar-overlay-root"); | |
| camera.camera.startXr(pc.XRTYPE_AR, pc.XRSPACE_LOCALFLOOR, { | |
| requiredFeatures: ["hit-test", "dom-overlay"], | |
| domOverlay: { root: app.xr.domOverlay.root }, | |
| callback: function (err) { | |
| if (err) { | |
| console.error("Échec du démarrage AR :", err); | |
| message("Échec du démarrage AR : " + (err.message || err)); | |
| } | |
| } | |
| }); | |
| } | |
| var startBtn = ensureARStartButton(activateAR); | |
| app.keyboard.on("keydown", function (evt) { if (evt.key === pc.KEY_ESCAPE && app.xr.active) app.xr.end(); }); | |
| // Hit-test principal (reticle + PREMIER placement) | |
| app.xr.hitTest.on("available", function () { | |
| app.xr.hitTest.start({ | |
| entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE], | |
| callback: function (err, hitSource) { | |
| if (err) { message("Le AR hit test n’a pas pu démarrer."); return; } | |
| hitSource.on("result", function (pos, rot) { | |
| if (!planeMatchesMode(rot)) return; | |
| reticle.enabled = true; | |
| reticle.setPosition(pos); | |
| reticle.setRotation(rot); | |
| if (modelLoaded && !placedOnce) { | |
| modelRoot.enabled = true; | |
| modelRoot.setPosition(pos); | |
| if (!wallMode) { | |
| // --- MODE SOL --- | |
| baseRot.copy(computeFloorBase()); | |
| // angle initial : récupérer un yaw “monde” pour démarrer aligné (optionnel) | |
| var e = new pc.Vec3(); rot.getEulerAngles(e); | |
| applyYawAroundLocalY(((e.y % 360) + 360) % 360); | |
| // Ombre au sol | |
| blob = createBlobShadowAt(pos, rot); | |
| } else { | |
| // --- MODE MUR (première pose) --- | |
| baseRot.copy(computeWallBaseFromHit(rot)); // Y=normale, Z=Up projeté | |
| applyYawAroundLocalY(0); // yaw utilisateur = 0 au départ | |
| } | |
| placedOnce = true; | |
| rotInput.disabled = false; | |
| message( | |
| wallMode | |
| ? "Objet placé contre le mur (Y=normale, Z→plafond). Glissez pour déplacer, tournez avec le slider →" | |
| : "Objet placé. Glissez pour déplacer, tournez-le avec le slider →" | |
| ); | |
| } | |
| }); | |
| } | |
| }); | |
| }); | |
| // Drag XR continu (déplacements suivants, sans rotation parasite) | |
| var rotateMode = false, lastMouseX = 0; | |
| var ROTATE_SENSITIVITY = 0.25; | |
| app.xr.input.on("add", function (inputSource) { | |
| inputSource.on("selectstart", function () { | |
| if (uiInteracting) return; | |
| if (!placedOnce || !modelLoaded) return; | |
| // Verrouille l'orientation pendant la translation | |
| dragLockedRot.copy(modelRoot.getRotation()); | |
| inputSource.hitTestStart({ | |
| entityTypes: [pc.XRTRACKABLE_POINT, pc.XRTRACKABLE_PLANE], | |
| callback: function (err, transientSource) { | |
| if (err) return; | |
| isDragging = true; | |
| transientSource.on("result", function (pos, rot) { | |
| if (!isDragging) return; | |
| if (!planeMatchesMode(rot)) return; | |
| lastHitRot.copy(rot); // garde trace du dernier plan | |
| modelRoot.setPosition(pos); // translate uniquement | |
| modelRoot.setRotation(dragLockedRot); // aucune rotation pendant le drag | |
| if (!wallMode) updateBlobPositionUnder(pos, rot); | |
| }); | |
| // Fin du drag : snap orientation sur NOUVEAU plan + ré-applique yaw local Y | |
| transientSource.once("remove", function () { | |
| isDragging = false; | |
| if (wallMode) { | |
| baseRot.copy(computeWallBaseFromHit(lastHitRot)); // recalcule Y/Z | |
| applyYawAroundLocalY(userYawDeg); // conserve l'angle utilisateur autour de Y local | |
| } else { | |
| // sol : garder baseRot (Y=Up monde), ne rien changer d'orientation | |
| modelRoot.setRotation(dragLockedRot); | |
| } | |
| }); | |
| } | |
| }); | |
| }); | |
| inputSource.on("selectend", function () { | |
| isDragging = false; | |
| }); | |
| }); | |
| // Desktop : rotation à la souris (Shift+drag gauche ou clic droit) | |
| app.mouse.on("mousedown", function (e) { | |
| if (!app.xr.active || !placedOnce || uiInteracting) return; | |
| if (e.button === 0 && !e.shiftKey) { | |
| isDragging = true; | |
| dragLockedRot.copy(modelRoot.getRotation()); | |
| } else if (e.button === 2 || (e.button === 0 && e.shiftKey)) { | |
| rotateMode = true; | |
| lastMouseX = e.x; | |
| } | |
| }); | |
| app.mouse.on("mousemove", function (e) { | |
| if (!app.xr.active || !placedOnce || uiInteracting) return; | |
| if (isDragging) { | |
| if (reticle.enabled) { | |
| var p = reticle.getPosition(); | |
| modelRoot.setPosition(p); | |
| modelRoot.setRotation(dragLockedRot); | |
| if (!wallMode) updateBlobPositionUnder(p, null); | |
| } | |
| } else if (rotateMode && modelRoot.enabled) { | |
| var dx = e.x - lastMouseX; | |
| lastMouseX = e.x; | |
| applyYawAroundLocalY(userYawDeg + dx * ROTATE_SENSITIVITY); | |
| } | |
| }); | |
| app.mouse.on("mouseup", function () { | |
| isDragging = false; | |
| rotateMode = false; | |
| }); | |
| window.addEventListener("contextmenu", function (e) { e.preventDefault(); }); | |
| // Slider (accessibilité clavier) | |
| rotInput.disabled = true; | |
| rotInput.addEventListener("input", function (e) { | |
| var v = parseFloat(e.target.value || "0"); | |
| applyYawAroundLocalY(v); | |
| }, { passive: true }); | |
| // Événements AR | |
| app.xr.on("start", function () { | |
| if (startBtn) startBtn.style.display = "none"; | |
| message(wallMode | |
| ? "Session AR démarrée. Visez un mur (Y=normale du mur, Z du modèle vers le plafond)…" | |
| : "Session AR démarrée. Visez le sol pour détecter un plan…"); | |
| reticle.enabled = true; | |
| }); | |
| app.xr.on("end", function () { | |
| if (startBtn) startBtn.style.display = ""; | |
| message("Session AR terminée."); | |
| reticle.enabled = false; | |
| isDragging = false; | |
| rotInput.disabled = true; | |
| }); | |
| app.xr.on("available:" + pc.XRTYPE_AR, function (a) { | |
| if (!a) message("AR immersive indisponible."); | |
| else if (!app.xr.hitTest.supported) message("AR Hit Test non supporté."); | |
| else message(modelLoaded ? "Appuyez sur « Lancer l’AR »." : "Chargement du modèle…"); | |
| }); | |
| if (!app.xr.isAvailable(pc.XRTYPE_AR)) message("AR immersive indisponible."); | |
| else if (!app.xr.hitTest.supported) message("AR Hit Test non supporté."); | |
| else message("Chargement du modèle…"); | |
| } | |
| })(); | |