/* script_ar.js — AR PlayCanvas + GLB HuggingFace (GLB via config.json)
- Lit config.json (data-config) => { "glb_url": "..." }
- Hit-test AR (HORIZONTAUX uniquement) + placement + drag (XR)
- Slider custom Yaw (360° en haut → 0° en bas), knob centré, rail plein
- Blocage total des interactions scène quand on touche le slider
- Éclairage PBR par défaut (sans WebXR light estimation)
- Blob Shadow (ombre de contact) sous l’objet
*/
(function () {
// ===== Récup config.json depuis data-config =====
function findConfigUrl() {
var el = document.currentScript || null;
if (!el) {
var scripts = document.getElementsByTagName('script');
for (var i = scripts.length - 1; i >= 0; i--) {
if (scripts[i].getAttribute && scripts[i].getAttribute('data-config')) {
el = scripts[i];
break;
}
}
}
if (!el) return null;
var url = el.getAttribute('data-config');
return url || null;
}
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 version fixée =====
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();
}
}
// ===== UI / Overlay =====
var css = [
".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}",
"#xr-overlay-root{position:fixed;inset:0;z-index:9999;pointer-events:none}",
".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}",
".ar-ui .label{font-size:12px;text-align:center;opacity:.95}",
".rotY-wrap{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}",
".rotY-knob{position:absolute;left:50%;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.35);transform:translate(-50%,-50%);top:50%;will-change:top;touch-action:none;pointer-events:none}",
".ar-ui input[type=\"range\"].rotY{position:absolute;opacity:0;pointer-events:none;width:0;height:0}",
".ar-ui .val{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("xr-overlay-root");
if (!r) {
r = document.createElement("div");
r.id = "xr-overlay-root";
document.body.appendChild(r);
}
return r;
}
var overlayRoot = ensureOverlayRoot();
function message(msg) {
var el = overlayRoot.querySelector(".pc-ar-msg");
if (!el) {
el = document.createElement("div");
el.className = "pc-ar-msg";
overlayRoot.appendChild(el);
}
el.textContent = msg;
}
function ensureCanvas() {
var 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;
}
function ensureSliderUI() {
var p = overlayRoot.querySelector(".ar-ui");
if (p) return p;
p = document.createElement("div");
p.className = "ar-ui";
p.innerHTML =
'
Rotation
' +
'' +
'0°
';
overlayRoot.appendChild(p);
return p;
}
// ===== Boot : charge config.json => GLB_URL => PlayCanvas => App =====
(async function () {
// 1) Lire la config (glb_url)
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";
// 2) Charger PlayCanvas
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;
}
// 3) Lancer l'app avec l'URL lue
initARApp(GLB_URL);
})();
// ===== App =====
function initARApp(GLB_URL) {
var pc = window.pc;
var canvas = ensureCanvas();
var ui = ensureSliderUI();
var rotWrap = ui.querySelector("#ar-rotY-wrap");
var rotKnob = ui.querySelector("#ar-rotY-knob");
var rotYInput = ui.querySelector("#ar-rotY");
var rotYVal = ui.querySelector("#ar-rotY-val");
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 defaults =====
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);
// Camera + 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;
// ===== Blob Shadow =====
var blob = null; // entité plane de l’ombre
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)'); // centre sombre
grd.addColorStop(1, 'rgba(0,0,0,0.0)'); // bords transparents
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; // utilise alpha pour la transparence
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;
}
// Euler base
var baseEulerX = 0, baseEulerZ = 0;
// Rotation via slider
var rotationYDeg = 0;
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) + "%";
rotYInput.value = String(Math.round(yDeg));
rotYVal.textContent = String(Math.round(yDeg)) + "°";
}
function applyRotationY(deg) {
var y = clamp360(deg);
rotationYDeg = y;
modelRoot.setEulerAngles(baseEulerX, y, baseEulerZ);
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 (depuis config.json)
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);
// Fix matériaux (anti-objets trop sombres)
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();
}
}
var initE = modelRoot.getEulerAngles();
baseEulerX = initE.x; baseEulerZ = initE.z;
modelLoaded = true;
message("Modèle chargé. Touchez l’écran pour démarrer l’AR.");
});
if (!app.xr.supported) { message("WebXR n’est pas supporté sur cet appareil."); return; }
// ===== Slider fiable : Pointer Events en CAPTURE =====
var uiInteracting = false;
var draggingWrap = false;
var activePointerId = null;
function insideWrap(target) { return rotWrap.contains(target); }
function degFromPointer(e) {
var rect = rotWrap.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 (!insideWrap(e.target)) return;
uiInteracting = true;
draggingWrap = true;
activePointerId = (e.pointerId != null) ? e.pointerId : 1;
if (rotWrap.setPointerCapture) {
try { rotWrap.setPointerCapture(activePointerId); } catch (er) {}
}
applyRotationY(degFromPointer(e));
e.preventDefault();
e.stopPropagation();
}
function onPointerMoveCapture(e) {
if (!draggingWrap || ((e.pointerId != null ? e.pointerId : 1) !== activePointerId)) return;
applyRotationY(degFromPointer(e));
e.preventDefault();
e.stopPropagation();
}
function endDrag(e) {
if (!draggingWrap || ((e.pointerId != null ? e.pointerId : 1) !== activePointerId)) return;
draggingWrap = false;
uiInteracting = false;
if (rotWrap.releasePointerCapture) {
try { rotWrap.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
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("xr-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));
}
}
});
}
app.mouse.on("mousedown", function () { if (!app.xr.active && !uiInteracting) activateAR(); });
if (app.touch) {
app.touch.on("touchend", function (evt) {
if (!app.xr.active && !uiInteracting) activateAR();
evt.event.preventDefault();
evt.event.stopPropagation();
});
}
app.keyboard.on("keydown", function (evt) { if (evt.key === pc.KEY_ESCAPE && app.xr.active) app.xr.end(); });
// ====== Filtre HORIZONTAL uniquement ======
var TMP_IN = new pc.Vec3(0, 1, 0), TMP_OUT = new pc.Vec3();
function isHorizontalUpFacing(rot, minDot) {
minDot = (typeof minDot === "number") ? minDot : 0.75;
rot.transformVector(TMP_IN, TMP_OUT);
return TMP_OUT.y >= minDot;
}
// Hit Test global (réticule + 1er 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 (!isHorizontalUpFacing(rot)) return;
reticle.enabled = true;
reticle.setPosition(pos);
reticle.setRotation(rot);
if (modelLoaded && !placedOnce) {
modelRoot.enabled = true;
modelRoot.setPosition(pos);
// Ombre de contact au placement initial
blob = createBlobShadowAt(pos, rot);
var e = new pc.Vec3();
rot.getEulerAngles(e);
var y0 = ((e.y % 360) + 360) % 360;
applyRotationY(y0);
placedOnce = true;
rotYInput.disabled = false;
message("Objet placé. Glissez pour déplacer, tournez-le avec le slider →");
}
});
}
});
});
// Déplacement XR (drag) — ignoré si UI active
var isDragging = false;
app.xr.input.on("add", function (inputSource) {
inputSource.on("selectstart", function () {
if (uiInteracting) return;
if (!placedOnce || !modelLoaded) return;
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 (!isHorizontalUpFacing(rot)) return;
modelRoot.setPosition(pos);
updateBlobPositionUnder(pos, rot);
});
transientSource.once("remove", function () { isDragging = false; });
}
});
});
inputSource.on("selectend", function () { isDragging = false; });
});
// Desktop : rotation souris (ignore si UI)
var rotateMode = false, lastMouseX = 0;
var ROTATE_SENSITIVITY = 0.25;
app.mouse.on("mousedown", function (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;
}
});
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);
updateBlobPositionUnder(p, null);
}
} else if (rotateMode && modelRoot.enabled) {
var dx = e.x - lastMouseX;
lastMouseX = e.x;
applyRotationY(rotationYDeg + dx * ROTATE_SENSITIVITY);
}
});
app.mouse.on("mouseup", function () { isDragging = false; rotateMode = false; });
window.addEventListener("contextmenu", function (e) { e.preventDefault(); });
// Slider (accessibilité clavier)
rotYInput.disabled = true;
rotYInput.addEventListener("input", function (e) {
if (!modelRoot.enabled) return;
var v = parseFloat(e.target.value || "0");
applyRotationY(v);
}, { passive: true });
// AR events
app.xr.on("start", function () { message("Session AR démarrée. Visez le sol pour détecter un plan…"); reticle.enabled = true; });
app.xr.on("end", function () { message("Session AR terminée."); reticle.enabled = false; isDragging = false; rotateMode = false; rotYInput.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 ? "Touchez l’écran pour démarrer 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…");
}
})();