VR / viewer_ar_ios.js
MikaFil's picture
Update viewer_ar_ios.js
f98b070 verified
/* 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…");
}
})();