// viewer_pr_env.js — Ammo Physics + Box Colliders from GLB AABBs // ============================================================================ // - Charge PlayCanvas (ESM) // - Charge Ammo (WASM) avec fallback JS et timeout (anti “écran noir”) // - Instancie le GLB d’environnement et génère des colliders ‘box’ statiques // en lisant les AABB des meshInstances (aucun asset collision requis) // - Crée un “player” capsule dynamique et attache la caméra en enfant (yeux) // - Conserve GSplat (.sog) si fourni // - Logs détaillés pour le debug // ============================================================================ /* ------------------------------------------- Surface d’erreurs globale (utile sur HF/iframes) -------------------------------------------- */ window.addEventListener('error', e => { console.error('[BOOT] Uncaught error:', e.message || e.error, e); }); window.addEventListener('unhandledrejection', e => { console.error('[BOOT] Unhandled promise rejection:', e.reason); }); console.log('[BOOT] JS boot reached'); /* ------------------------------------------- Utils -------------------------------------------- */ function hexToRgbaArray(hex) { try { hex = String(hex || "").replace("#", ""); if (hex.length === 6) hex += "FF"; if (hex.length !== 8) return [1, 1, 1, 1]; const num = parseInt(hex, 16); return [ ((num >> 24) & 0xff) / 255, ((num >> 16) & 0xff) / 255, ((num >> 8) & 0xff) / 255, (num & 0xff) / 255 ]; } catch (e) { console.warn("hexToRgbaArray error:", e); return [1, 1, 1, 1]; } } function traverse(entity, callback) { callback(entity); if (entity.children && entity.children.length) { entity.children.forEach((child) => traverse(child, callback)); } } function isMobileUA() { const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); const isAndroid = /Android/i.test(navigator.userAgent); return isIOS || isAndroid; } // Patch global Image -> force CORS (function () { const OriginalImage = window.Image; window.Image = function (...args) { const img = new OriginalImage(...args); img.crossOrigin = "anonymous"; return img; }; })(); /* ------------------------------------------- Chargement Ammo (WASM + fallback JS) avec timeout -------------------------------------------- */ async function loadAmmoWithFallback(pc, baseUrl = "https://playcanvas.github.io/examples/lib/ammo/", timeoutMs = 5000) { try { pc.WasmModule.setConfig("Ammo", { glueUrl: baseUrl + "ammo.wasm.js", wasmUrl: baseUrl + "ammo.wasm.wasm", fallbackUrl: baseUrl + "ammo.js" }); const p = pc.WasmModule.getInstance("Ammo", baseUrl + "ammo.wasm.js"); if (p && typeof p.then === "function") { await Promise.race([ p, new Promise((_, rej) => setTimeout(() => rej(new Error("Ammo load timeout (promise)")), timeoutMs)) ]); console.log("[Ammo] WASM ready (promise API)."); return true; } // Callback API (rarement nécessaire) await Promise.race([ new Promise((resolve) => pc.WasmModule.getInstance("Ammo", resolve)), new Promise((_, rej) => setTimeout(() => rej(new Error("Ammo load timeout (callback)")), timeoutMs)) ]); console.log("[Ammo] JS fallback ready (callback API)."); return true; } catch (e) { console.warn("[Ammo] load failed:", e); return false; } } /* ------------------------------------------- State (module / instance) -------------------------------------------- */ let pc; export let app = null; let playerEntity = null; // capsule dynamique (rigidbody) let cameraEntity = null; // caméra enfant let modelEntity = null; // GSplat principal (optionnel) let envEntity = null; // GLB instancié (environnement render) let viewerInitialized = false; let resizeObserver = null; let resizeTimeout = null; // Config / paramètres courants let sogUrl, glbUrl, presentoirUrl; let color_bg_hex, color_bg; let espace_expo_bool; // Camera spawn let chosenCameraX, chosenCameraY, chosenCameraZ; // DPR / perf let maxDevicePixelRatio = 1.75; let interactDpr = 1.0; let idleRestoreDelay = 350; let idleTimer = null; // Physique let physicsEnabled = true; // si Ammo indisponible -> false let ammoBaseUrl = "https://playcanvas.github.io/examples/lib/ammo/"; let freeFly = false; // si true => gravity zero (option) /* ------------------------------------------- Script FPS minimal (yaw/pitch + ZQSD) (nom = 'orbitCamera' pour compat) -------------------------------------------- */ function ensureFirstPersonScriptRegistered() { if (window.__PLY_FPS_REG__) return; window.__PLY_FPS_REG__ = true; var FPS = pc.createScript('orbitCamera'); FPS.attributes.add('cameraEntity', { type: 'entity', title: 'Camera (child)' }); FPS.attributes.add('moveSpeed', { type: 'number', default: 4.0, title: 'Move Speed (m/s)' }); FPS.attributes.add('lookSpeed', { type: 'number', default: 0.25, title: 'Look Sensitivity' }); FPS.attributes.add('pitchAngleMin', { type: 'number', default: -89, title: 'Pitch Min (deg)' }); FPS.attributes.add('pitchAngleMax', { type: 'number', default: 89, title: 'Pitch Max (deg)' }); FPS.attributes.add('freeFly', { type: 'boolean', default: false, title: 'Free Fly (no gravity)' }); FPS.prototype.initialize = function () { this.yaw = 0; this.pitch = 0; this._v = new pc.Vec3(); // init yaw/pitch depuis rotations actuelles var qCam = (this.cameraEntity || this.entity).getRotation(); var f = new pc.Vec3(); qCam.transformVector(pc.Vec3.FORWARD, f); this.yaw = Math.atan2(f.x, f.z) * pc.math.RAD_TO_DEG; var yawQ = new pc.Quat().setFromEulerAngles(0, -this.yaw, 0); var noYawQ = new pc.Quat().mul2(yawQ, qCam); var fNoYaw = new pc.Vec3(); noYawQ.transformVector(pc.Vec3.FORWARD, fNoYaw); this.pitch = pc.math.clamp(Math.atan2(-fNoYaw.y, -fNoYaw.z) * pc.math.RAD_TO_DEG, this.pitchAngleMin, this.pitchAngleMax); // pointer lock au clic gauche if (this.app.mouse) { this.app.mouse.on(pc.EVENT_MOUSEDOWN, (e) => { if (e.button === pc.MOUSEBUTTON_LEFT && document.activeElement === this.app.graphicsDevice.canvas) { const c = this.app.graphicsDevice.canvas; if (c.requestPointerLock) c.requestPointerLock(); } }, this); this.app.mouse.on(pc.EVENT_MOUSEMOVE, (e) => { if (document.pointerLockElement !== this.app.graphicsDevice.canvas) return; this.yaw -= e.dx * this.lookSpeed; this.pitch -= e.dy * this.lookSpeed; this.pitch = pc.math.clamp(this.pitch, this.pitchAngleMin, this.pitchAngleMax); }, this); } }; FPS.prototype.update = function (dt) { // yaw/pitch this.entity.setLocalEulerAngles(0, this.yaw, 0); if (this.cameraEntity) this.cameraEntity.setLocalEulerAngles(this.pitch, 0, 0); // mouvements const kb = this.app.keyboard; let fwd = 0, str = 0, up = 0; if (kb) { fwd += (kb.isPressed(pc.KEY_W) || kb.isPressed(pc.KEY_Z) || kb.isPressed(pc.KEY_UP)) ? 1 : 0; fwd -= (kb.isPressed(pc.KEY_S) || kb.isPressed(pc.KEY_DOWN)) ? 1 : 0; str += (kb.isPressed(pc.KEY_D) || kb.isPressed(pc.KEY_RIGHT)) ? 1 : 0; str -= (kb.isPressed(pc.KEY_A) || kb.isPressed(pc.KEY_Q) || kb.isPressed(pc.KEY_LEFT)) ? 1 : 0; // free-fly: E/Space monte, C/Ctrl descend if (this.freeFly) { up += (kb.isPressed(pc.KEY_E) || kb.isPressed(pc.KEY_SPACE)) ? 1 : 0; up -= (kb.isPressed(pc.KEY_C) || kb.isPressed(pc.KEY_CTRL)) ? 1 : 0; } } const body = this.entity.rigidbody; if (!body) return; // directions const f = this.entity.forward.clone(); const r = this.entity.right.clone(); if (!this.freeFly) { f.y = 0; r.y = 0; } if (f.lengthSq()>1e-8) f.normalize(); if (r.lengthSq()>1e-8) r.normalize(); // vélocité cible const target = new pc.Vec3() .add(f.mulScalar(fwd * this.moveSpeed)) .add(r.mulScalar(str * this.moveSpeed)); if (this.freeFly) target.y = up * this.moveSpeed; else target.y = body.linearVelocity.y; // conserve gravité // applique this._v.copy(body.linearVelocity); this._v.lerp(this._v, target, Math.min(1, dt*10)); body.linearVelocity = this._v; }; } /* ------------------------------------------- Initialisation principale -------------------------------------------- */ export async function initializeViewer(config, instanceId) { if (viewerInitialized) return; const mobile = isMobileUA(); console.log("[VIEWER] A: initializeViewer begin ", { instanceId }); // ---- Lecture config ---- sogUrl = config.sog_url || config.sogs_json_url || null; glbUrl = (config.glb_url !== undefined) ? config.glb_url : null; presentoirUrl = (config.presentoir_url !== undefined) ? config.presentoir_url : null; color_bg_hex = config.canvas_background !== undefined ? config.canvas_background : "#FFFFFF"; espace_expo_bool = config.espace_expo_bool !== undefined ? !!config.espace_expo_bool : false; color_bg = hexToRgbaArray(color_bg_hex); // Camera spawn — valeurs par défaut const camX = config.cameraX !== undefined ? parseFloat(config.cameraX) : 0; const camY = config.cameraY !== undefined ? parseFloat(config.cameraY) : 1.6; const camZ = config.cameraZ !== undefined ? parseFloat(config.cameraZ) : 4.0; const camXPhone = config.cameraXPhone !== undefined ? parseFloat(config.cameraXPhone) : camX; const camYPhone = config.cameraYPhone !== undefined ? parseFloat(config.cameraYPhone) : camY; const camZPhone = config.cameraZPhone !== undefined ? parseFloat(config.cameraZPhone) : camZ * 1.5; chosenCameraX = mobile ? camXPhone : camX; chosenCameraY = mobile ? camYPhone : camY; chosenCameraZ = mobile ? camZPhone : camZ; // DPR / perf if (config.maxDevicePixelRatio !== undefined) { maxDevicePixelRatio = Math.max(0.75, parseFloat(config.maxDevicePixelRatio) || maxDevicePixelRatio); } if (config.interactionPixelRatio !== undefined) { interactDpr = Math.max(0.75, parseFloat(config.interactionPixelRatio) || interactDpr); } if (config.idleRestoreDelayMs !== undefined) { idleRestoreDelay = Math.max(120, parseInt(config.idleRestoreDelayMs, 10) || idleRestoreDelay); } // Physique physicsEnabled = config.usePhysics === false ? false : true; // par défaut true freeFly = !!config.freeFly; if (config.ammoBaseUrl) { ammoBaseUrl = String(config.ammoBaseUrl).endsWith("/") ? config.ammoBaseUrl : (config.ammoBaseUrl + "/"); } // ---- Canvas / DOM ---- const canvasId = "canvas-" + instanceId; const progressDialog = document.getElementById("progress-dialog-" + instanceId); const viewerContainer = document.getElementById("viewer-container-" + instanceId); const old = document.getElementById(canvasId); if (old) old.remove(); const canvas = document.createElement("canvas"); canvas.id = canvasId; canvas.className = "ply-canvas"; canvas.style.width = "100%"; canvas.style.height = "100%"; canvas.setAttribute("tabindex", "0"); viewerContainer.insertBefore(canvas, progressDialog); // interactions de base canvas.style.touchAction = "none"; canvas.style.webkitTouchCallout = "none"; canvas.addEventListener("gesturestart", (e) => e.preventDefault()); canvas.addEventListener("gesturechange", (e) => e.preventDefault()); canvas.addEventListener("gestureend", (e) => e.preventDefault()); canvas.addEventListener("dblclick", (e) => e.preventDefault()); canvas.addEventListener("wheel", (e) => e.preventDefault(), { passive: false }); const scrollKeys = new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","PageUp","PageDown","Home","End"," ","Space","Spacebar"]); let isPointerOverCanvas = false; const focusCanvas = () => canvas.focus({ preventScroll: true }); const onPointerEnter = () => { isPointerOverCanvas = true; focusCanvas(); }; const onPointerLeave = () => { isPointerOverCanvas = false; if (document.activeElement === canvas) canvas.blur(); }; const onCanvasBlur = () => { isPointerOverCanvas = false; }; canvas.addEventListener("pointerenter", onPointerEnter); canvas.addEventListener("pointerleave", onPointerLeave); canvas.addEventListener("mouseenter", onPointerEnter); canvas.addEventListener("mouseleave", onPointerLeave); canvas.addEventListener("mousedown", focusCanvas); canvas.addEventListener("touchstart", focusCanvas, { passive: true }); canvas.addEventListener("blur", onCanvasBlur); const onKeyDownCapture = (e) => { if (!isPointerOverCanvas) return; if (scrollKeys.has(e.key) || scrollKeys.has(e.code)) e.preventDefault(); }; window.addEventListener("keydown", onKeyDownCapture, true); progressDialog.style.display = "block"; // ---- Import PlayCanvas ---- if (!pc) { pc = await import("https://esm.run/playcanvas"); window.pc = pc; // debug } console.log("[VIEWER] PlayCanvas ESM chargé:", !!pc); // ---- Charge Ammo si activé ---- if (physicsEnabled) { const ammoOk = await loadAmmoWithFallback(pc, ammoBaseUrl, mobile ? 6000 : 4000); physicsEnabled = !!ammoOk; console.log("[VIEWER] Ammo status:", physicsEnabled ? "OK" : "DISABLED"); } // ---- Crée l’Application ---- const device = await pc.createGraphicsDevice(canvas, { deviceTypes: ["webgl2", "webgl1"], antialias: false }); device.maxPixelRatio = Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio); const opts = new pc.AppOptions(); opts.graphicsDevice = device; opts.mouse = new pc.Mouse(canvas); opts.touch = new pc.TouchDevice(canvas); opts.keyboard= new pc.Keyboard(canvas); // IMPORTANT : inclure Collision et Rigidbody opts.componentSystems = [ pc.RenderComponentSystem, pc.CameraComponentSystem, pc.LightComponentSystem, pc.ScriptComponentSystem, pc.GSplatComponentSystem, pc.CollisionComponentSystem, pc.RigidbodyComponentSystem ]; opts.resourceHandlers = [pc.TextureHandler, pc.ContainerHandler, pc.ScriptHandler, pc.GSplatHandler]; app = new pc.Application(canvas, opts); app.setCanvasFillMode(pc.FILLMODE_NONE); app.setCanvasResolution(pc.RESOLUTION_AUTO); // Gravité selon le mode app.scene.gravity = freeFly ? new pc.Vec3(0, 0, 0) : new pc.Vec3(0, -9.81, 0); // Resize observé (debounce) resizeObserver = new ResizeObserver((entries) => { if (!entries || !entries.length) return; if (resizeTimeout) clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { app.resizeCanvas(entries[0].contentRect.width, entries[0].contentRect.height); }, 60); }); resizeObserver.observe(viewerContainer); window.addEventListener("resize", () => { if (resizeTimeout) clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight); }, 60); }); app.on("destroy", () => { try { resizeObserver.disconnect(); } catch {} if (opts.keyboard && opts.keyboard.detach) opts.keyboard.detach(); window.removeEventListener("keydown", onKeyDownCapture, true); canvas.removeEventListener("pointerenter", onPointerEnter); canvas.removeEventListener("pointerleave", onPointerLeave); canvas.removeEventListener("mouseenter", onPointerEnter); canvas.removeEventListener("mouseleave", onPointerLeave); canvas.removeEventListener("mousedown", focusCanvas); canvas.removeEventListener("touchstart", focusCanvas); canvas.removeEventListener("blur", onCanvasBlur); }); // ---- Assets (SOG + GLB) ---- const assets = []; let sogAsset = null, glbAsset = null; if (sogUrl) { sogAsset = new pc.Asset("gsplat", "gsplat", { url: sogUrl }); app.assets.add(sogAsset); assets.push(sogAsset); } if (glbUrl) { glbAsset = new pc.Asset("glb", "container", { url: glbUrl }); app.assets.add(glbAsset); assets.push(glbAsset); } else { console.warn("[VIEWER] Aucun glb_url fourni — rien à afficher."); } // Script FPS (compat nom 'orbitCamera') ensureFirstPersonScriptRegistered(); // Charge les assets requis await new Promise((resolve, reject) => { const loader = new pc.AssetListLoader(assets, app.assets); loader.load(() => resolve()); loader.on('error', (e)=>{ console.error('[VIEWER] Asset load error:', e); reject(e); }); }); app.start(); progressDialog.style.display = "none"; console.log("[VIEWER] app.start OK — assets chargés"); // ---- GSplat (optionnel) ---- if (sogAsset) { modelEntity = new pc.Entity("model"); modelEntity.addComponent("gsplat", { asset: sogAsset }); app.root.addChild(modelEntity); } // ---- Environnement GLB ---- envEntity = glbAsset && glbAsset.resource ? glbAsset.resource.instantiateRenderEntity() : null; if (envEntity) { envEntity.name = "ENV_GLTF"; app.root.addChild(envEntity); // Mat “fond uni” si demandé if (!espace_expo_bool) { const matSol = new pc.StandardMaterial(); matSol.blendType = pc.BLEND_NONE; matSol.emissive = new pc.Color(color_bg); matSol.emissiveIntensity = 1; matSol.useLighting = false; matSol.update(); traverse(envEntity, (node) => { if (node.render && node.render.meshInstances) { for (const mi of node.render.meshInstances) mi.material = matSol; } }); } // ---- Colliders statiques “box” depuis AABB de chaque meshInstance ---- // Avantage : pas besoin d'asset collision, super robuste. if (physicsEnabled) { let rawCount = 0; let created = 0; traverse(envEntity, (node) => { if (node.render && node.render.meshInstances && node.render.meshInstances.length) { for (const mi of node.render.meshInstances) { rawCount++; const aabb = mi.aabb; // monde (pour render instancié, AABB est world-space) const center = aabb.center.clone(); const he = aabb.halfExtents.clone(); // crée un enfant collider “box” placé au centre de l’AABB const box = new pc.Entity(`COLL_BOX_${created}`); box.setPosition(center); box.addComponent('collision', { type: 'box', halfExtents: he }); box.addComponent('rigidbody', { type: 'static', friction: 0.6, restitution: 0.0 }); app.root.addChild(box); created++; } } }); console.log(`[VIEWER] Colliders statiques créés: ${created} (raw meshInstances=${rawCount})`); } else { console.warn("[VIEWER] Physics disabled: aucun collider statique créé."); } } else { console.warn("[VIEWER] GLB resource missing. Rien à collider."); } // ---- Caméra + Player capsule ---- cameraEntity = new pc.Entity("Camera"); cameraEntity.addComponent("camera", { clearColor: new pc.Color(color_bg), nearClip: 0.03, farClip: 500 }); if (physicsEnabled) { playerEntity = new pc.Entity("Player"); const capsuleRadius = config.capsuleRadius !== undefined ? parseFloat(config.capsuleRadius) : 0.35; const capsuleHeight = config.capsuleHeight !== undefined ? parseFloat(config.capsuleHeight) : 1.70; playerEntity.addComponent('collision', { type: 'capsule', radius: capsuleRadius, height: capsuleHeight }); playerEntity.addComponent('rigidbody', { type: 'dynamic', mass: 75, friction: 0.3, restitution: 0.0, linearDamping: 0.15, angularDamping: 0.999 }); // Bloque la rotation pour éviter le roulis playerEntity.rigidbody.angularFactor = new pc.Vec3(0, 0, 0); // Spawn playerEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ); app.root.addChild(playerEntity); // Caméra à hauteur des yeux const eyes = config.eyesOffsetY !== undefined ? parseFloat(config.eyesOffsetY) : Math.max(0.4, capsuleHeight * 0.9 - capsuleRadius); // ~ yeux cameraEntity.setLocalPosition(0, eyes, 0); playerEntity.addChild(cameraEntity); // Script FPS playerEntity.addComponent("script"); playerEntity.script.create("orbitCamera", { attributes: { cameraEntity: cameraEntity, moveSpeed: (config.moveSpeed !== undefined ? parseFloat(config.moveSpeed) : 4.0), lookSpeed: (config.lookSpeed !== undefined ? parseFloat(config.lookSpeed) : 0.25), pitchAngleMin: (config.minAngle !== undefined ? parseFloat(config.minAngle) : -89), pitchAngleMax: (config.maxAngle !== undefined ? parseFloat(config.maxAngle) : 89), freeFly: !!freeFly } }); } else { // Pas de physique : caméra seule (pas recommandé si tu veux collisions) cameraEntity.setPosition(chosenCameraX, chosenCameraY, chosenCameraZ); app.root.addChild(cameraEntity); } // Regarder vers le modèle si présent, sinon vers l’origine const lookTarget = modelEntity ? modelEntity.getPosition() : new pc.Vec3(0, 1, 0); cameraEntity.lookAt(lookTarget); // Taille initiale app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight); // DPR dynamique : réduit pendant interaction const setDpr = (val) => { const clamped = Math.max(0.5, Math.min(val, maxDevicePixelRatio)); if (app.graphicsDevice.maxPixelRatio !== clamped) { app.graphicsDevice.maxPixelRatio = clamped; app.resizeCanvas(viewerContainer.clientWidth, viewerContainer.clientHeight); } }; const bumpInteraction = () => { setDpr(interactDpr); if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(() => { setDpr(Math.min(window.devicePixelRatio || 1, maxDevicePixelRatio)); }, idleRestoreDelay); }; ["mousedown", "mousemove", "mouseup", "wheel", "touchstart", "touchmove", "keydown"] .forEach((ev) => canvas.addEventListener(ev, bumpInteraction, { passive: true })); viewerInitialized = true; console.log("[VIEWER] READY — physics=", physicsEnabled ? "ON" : "OFF", "env=", !!envEntity, "sog=", !!modelEntity); } /* ------------------------------------------- API helper : repositionner le joueur/caméra -------------------------------------------- */ export function resetViewerCamera(x, y, z) { try { if (!app) return; const nx = (x !== undefined) ? parseFloat(x) : null; const ny = (y !== undefined) ? parseFloat(y) : null; const nz = (z !== undefined) ? parseFloat(z) : null; if (playerEntity && playerEntity.rigidbody) { const p = playerEntity.getPosition().clone(); playerEntity.rigidbody.teleport(nx ?? p.x, ny ?? p.y, nz ?? p.z, playerEntity.getRotation()); playerEntity.rigidbody.linearVelocity = new pc.Vec3(0, 0, 0); playerEntity.rigidbody.angularVelocity = new pc.Vec3(0, 0, 0); } else if (cameraEntity) { const p = cameraEntity.getPosition().clone(); cameraEntity.setPosition(nx ?? p.x, ny ?? p.y, nz ?? p.z); } } catch (e) { console.error("[viewer_pr_env] resetViewerCamera error:", e); } }