| |
| |
| |
| |
| |
| |
| |
| |
|
|
| (function () { |
| |
| 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; |
| } |
| } |
|
|
| |
| 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( 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) { } |
| } |
| 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) { } |
| } |
| throw new Error("UMD failed"); |
| } |
|
|
| try { |
| if (esmFirst) return await tryESM(); |
| return await tryUMD(); |
| } catch (e) { |
| if (esmFirst) return await tryUMD(); |
| return await tryESM(); |
| } |
| } |
|
|
| |
| 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 = |
| '<div class="label">Rotation</div>' + |
| '<div class="rotY-wrap" id="ar-rotY-wrap">' + |
| ' <div class="rotY-rail"></div>' + |
| ' <div class="rotY-knob" id="ar-rotY-knob"></div>' + |
| ' <input class="rotY" id="ar-rotY" type="range" min="0" max="360" step="1" value="0"/>' + |
| '</div>' + |
| '<div class="val" id="ar-rotY-val">0°</div>'; |
| overlayRoot.appendChild(p); |
| return p; |
| } |
|
|
| |
| (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"; |
|
|
| |
| 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); |
| })(); |
|
|
| |
| 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(); |
|
|
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| var modelRoot = new pc.Entity("ModelRoot"); |
| modelRoot.enabled = false; |
| app.root.addChild(modelRoot); |
| var modelLoaded = false, placedOnce = false; |
|
|
| |
| 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; |
| } |
|
|
| |
| var baseEulerX = 0, baseEulerZ = 0; |
|
|
| |
| 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); |
| } |
|
|
| |
| 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); |
|
|
| |
| 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; } |
|
|
| |
| 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 }); |
|
|
| |
| 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(); }); |
|
|
| |
| 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; |
| } |
|
|
| |
| 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); |
|
|
| |
| 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 →"); |
| } |
| }); |
| } |
| }); |
| }); |
|
|
| |
| 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; }); |
| }); |
|
|
| |
| 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(); }); |
|
|
| |
| rotYInput.disabled = true; |
| rotYInput.addEventListener("input", function (e) { |
| if (!modelRoot.enabled) return; |
| var v = parseFloat(e.target.value || "0"); |
| applyRotationY(v); |
| }, { passive: true }); |
|
|
| |
| 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…"); |
| } |
| })(); |
|
|