/* Lightloom · frontend/js/explore3d.js * ============================================================================ * NAVIGABLE 3D — "step into your finished world". * * A self-contained Three.js viewer (NEVER touches the live stage-scroll renderer) that lifts the * finished painterly panorama into real 3D: a subdivided plane whose vertices are DISPLACED by the * Depth-Anything depth map (2D -> 2.5D -> 3D, the depth-displaced-mesh recipe from the WebGL research * doc), textured with the panorama. The user DRAGS to orbit (tilting pops the depth: near brushwork * parallaxes against the far background), wheels/pinches to dolly in, and scrubs a slider to travel the * mural. Disocclusion "rubber-sheet" triangles at depth cliffs are masked (SceneScape gradient discard), * so edges reveal honest gaps instead of smears. 100% CLIENT GPU — the Space's ZeroGPU is never touched. * * window.LL.explore3d = { open({color, depth, title}), close() } * ==========================================================================*/ "use strict"; import * as THREE from "three"; window.LL = window.LL || {}; const Explore3D = (() => { let host = null, renderer = null, scene = null, camera = null, mesh = null, mat = null, backdrop = null, raf = 0; let hintEl = null, hintTimer = 0, _onWinMove = null, _onWinUp = null; // fade the "drag to peer" hint out the first time the user interacts, or after a grace period. function fadeHint() { if (hintEl) hintEl.classList.add("is-faded"); if (hintTimer) { clearTimeout(hintTimer); hintTimer = 0; } } let target = null; // THREE.Vector3 look-point on the strip (x travels along the mural) let yaw = 0, pitch = 0, dist = 1.5, worldW = 4, halfVisU = 0.9, open = false; const YAW_MAX = 0.36, PITCH_MAX = 0.22, DIST_MIN = 0.9, DIST_MAX = 3.0; // clamps: gentle peer, never extreme angles (tearing) // Load an image URL and return a THREE.CanvasTexture downscaled to <= MAX_TEXTURE_SIZE wide (a session // panorama can be 8k-25k px; Android caps at 4096) so the upload never silently fails. Aspect preserved. function loadTex(url, sRGB) { return new Promise((resolve) => { const im = new Image(); im.crossOrigin = "anonymous"; im.onerror = () => resolve(null); im.onload = () => { try { let maxT = 4096; try { const gl = renderer.getContext(); maxT = Math.min(gl.getParameter(gl.MAX_TEXTURE_SIZE) || 4096, 8192); } catch (_) {} let w = im.width, h = im.height; if (w > maxT) { h = Math.round(h * (maxT / w)); w = maxT; } const cv = document.createElement("canvas"); cv.width = w; cv.height = h; cv.getContext("2d").drawImage(im, 0, 0, w, h); const tx = new THREE.CanvasTexture(cv); if (sRGB) tx.colorSpace = THREE.SRGBColorSpace; tx.minFilter = THREE.LinearFilter; tx.magFilter = THREE.LinearFilter; tx.wrapS = tx.wrapT = THREE.ClampToEdgeWrapping; tx.generateMipmaps = false; tx._w = w; tx._h = h; resolve(tx); } catch (_) { resolve(null); } }; im.src = url; }); } const VERT = ` precision highp float; uniform sampler2D uDep; uniform float uDisplace, uGamma, uGain; varying vec2 vUv; varying float vD; void main(){ vUv = uv; float d0 = texture2D(uDep, uv).r; // 0 = far, 1 = near float d = pow(clamp(d0, 0.0, 1.0), uGamma); d = clamp(0.5 + (d - 0.5) * uGain, 0.0, 1.0); vD = d; // taper displacement to 0 at the top/bottom borders so the frame rim stays flat (no torn edge) float edge = smoothstep(0.0, 0.07, uv.y) * smoothstep(1.0, 0.93, uv.y); vec3 p = position; p.z += (d - 0.5) * uDisplace * edge; // push near toward the camera, far away gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); }`; const FRAG = ` precision highp float; uniform sampler2D uImg, uDep; uniform vec2 uTexel; uniform float uEdge; varying vec2 vUv; varying float vD; vec3 toSRGB(vec3 c){ c=max(c,vec3(0.0)); vec3 lo=c*12.92; vec3 hi=1.055*pow(c,vec3(1.0/2.4))-0.055; return mix(lo,hi,step(vec3(0.0031308),c)); } void main(){ // SceneScape stretch-mask: drop fragments on a steep depth cliff (rubber-sheet smear) -> honest gap. float dl=texture2D(uDep, vUv-vec2(uTexel.x,0.)).r, dr=texture2D(uDep, vUv+vec2(uTexel.x,0.)).r; float dn=texture2D(uDep, vUv-vec2(0.,uTexel.y)).r, dd=texture2D(uDep, vUv+vec2(0.,uTexel.y)).r; float grad = length(vec2(dr-dl, dd-dn)); if (grad > uEdge) discard; vec3 c = toSRGB(texture2D(uImg, vUv).rgb); // uImg is sRGB -> decoded to linear on sample c *= mix(0.82, 1.06, vD); // near a touch brighter (aerial depth cue) float vy = smoothstep(0.0, 0.06, vUv.y) * smoothstep(1.0, 0.94, vUv.y); c *= mix(0.86, 1.0, vy); // soft top/bottom vignette gl_FragColor = vec4(c, 1.0); }`; function buildOverlay() { host = document.createElement("section"); host.className = "explore3d"; host.setAttribute("role", "dialog"); host.setAttribute("aria-modal", "true"); host.setAttribute("aria-label", "Explore your world in 3D"); const canvas = document.createElement("canvas"); canvas.className = "explore3d__canvas"; const close = document.createElement("button"); close.className = "explore3d__close"; close.type = "button"; close.setAttribute("aria-label", "Close"); close.textContent = "✕"; close.addEventListener("click", () => Explore3D.close()); const hint = document.createElement("p"); hint.className = "explore3d__hint"; hint.textContent = (document.documentElement.dataset.lang === "es") ? "Arrastra para asomarte · rueda para acercar · desliza para recorrer" : "Drag to peer · scroll to move closer · slide to travel"; const slider = document.createElement("input"); slider.type = "range"; slider.min = "0"; slider.max = "1000"; slider.value = "500"; slider.className = "explore3d__scrub"; slider.setAttribute("aria-label", "Travel along the world"); host.append(canvas, close, hint, slider); document.body.appendChild(host); return { canvas, slider, close, hint }; } function onKey(e) { if (e.key === "Escape") Explore3D.close(); } async function openFn({ color, depth, title, focal } = {}) { if (open) Explore3D.close(); if (!color || !depth) return false; const { canvas, slider, hint } = buildOverlay(); hintEl = hint; try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 1.5)); renderer.setClearColor(0x05060a, 1); } catch (_) { Explore3D.close(); return false; } scene = new THREE.Scene(); const [imgTex, depTex] = await Promise.all([loadTex(color, true), loadTex(depth, false)]); if (!imgTex || !depTex) { Explore3D.close(); return false; } worldW = (imgTex._w && imgTex._h) ? imgTex._w / imgTex._h : 4; // panorama aspect (wide) // grid resolution: enough to resolve depth edges along the wide strip, capped for mobile const mobile = Math.min(window.innerWidth, window.innerHeight) < 760; const segX = Math.max(96, Math.min(mobile ? 240 : 480, Math.round(worldW * (mobile ? 10 : 18)))); const segY = mobile ? 64 : 112; mat = new THREE.ShaderMaterial({ uniforms: { uImg: { value: imgTex }, uDep: { value: depTex }, uTexel: { value: new THREE.Vector2(1 / (depTex._w || 1024), 1 / (depTex._h || 256)) }, uDisplace: { value: 0.24 }, uGamma: { value: 0.85 }, uGain: { value: 1.2 }, uEdge: { value: 0.05 }, }, vertexShader: VERT, fragmentShader: FRAG, side: THREE.DoubleSide, }); mesh = new THREE.Mesh(new THREE.PlaneGeometry(worldW, 1, segX, segY), mat); scene.add(mesh); // BACKDROP: a flat, dimmed copy of the panorama BEHIND the displaced mesh, so the disocclusion holes // (discarded cliff fragments + scene-seam jumps) reveal the dim scene/atmosphere instead of black void. backdrop = new THREE.Mesh( new THREE.PlaneGeometry(worldW, 1), new THREE.MeshBasicMaterial({ map: imgTex, color: new THREE.Color(0.34, 0.34, 0.4) }), ); backdrop.position.z = -0.22; scene.add(backdrop); camera = new THREE.PerspectiveCamera(42, 1, 0.01, 100); halfVisU = Math.tan((42 * Math.PI / 180) / 2) * 1.5 * (16 / 9) / worldW; // visible fraction of the mural target = new THREE.Vector3(0, 0, 0); yaw = 0; pitch = 0; dist = 1.5; open = true; resize(); // open on the region MiniCPM's Art Director judged most striking (focal_points), else the middle const fx0 = (Array.isArray(focal) && focal[0] && typeof focal[0].x === "number") ? Math.min(1, Math.max(0, focal[0].x)) : 0.5; slider.value = String(Math.round(fx0 * 1000)); slider.addEventListener("input", () => { const u = (+slider.value) / 1000; const halfVis = halfVisU * worldW; target.x = (-worldW / 2 + halfVis) + u * (worldW - 2 * halfVis); fadeHint(); }); slider.dispatchEvent(new Event("input")); bindDrag(canvas); // hint fades on first interaction (drag/scrub/wheel) or after a 4.5s grace period if untouched. hintTimer = setTimeout(fadeHint, 4500); window.addEventListener("resize", resize); document.addEventListener("keydown", onKey); loop(); return true; } function bindDrag(canvas) { let drag = false, lx = 0, ly = 0; const down = (x, y) => { drag = true; lx = x; ly = y; fadeHint(); }; const move = (x, y) => { if (!drag) return; yaw = Math.max(-YAW_MAX, Math.min(YAW_MAX, yaw - (x - lx) * 0.0045)); pitch = Math.max(-PITCH_MAX, Math.min(PITCH_MAX, pitch + (y - ly) * 0.004)); lx = x; ly = y; }; const up = () => { drag = false; }; canvas.addEventListener("mousedown", (e) => down(e.clientX, e.clientY)); _onWinMove = (e) => move(e.clientX, e.clientY); // named so closeFn can remove them (no window leak) _onWinUp = up; window.addEventListener("mousemove", _onWinMove); window.addEventListener("mouseup", _onWinUp); canvas.addEventListener("touchstart", (e) => { const t = e.touches[0]; down(t.clientX, t.clientY); }, { passive: true }); canvas.addEventListener("touchmove", (e) => { const t = e.touches[0]; move(t.clientX, t.clientY); }, { passive: true }); canvas.addEventListener("touchend", up); canvas.addEventListener("wheel", (e) => { e.preventDefault(); dist = Math.max(DIST_MIN, Math.min(DIST_MAX, dist + Math.sign(e.deltaY) * 0.12)); fadeHint(); }, { passive: false }); } function resize() { if (!renderer || !camera) return; const w = window.innerWidth, h = window.innerHeight; renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); halfVisU = Math.tan((camera.fov * Math.PI / 180) / 2) * dist * camera.aspect / worldW; } function loop() { raf = requestAnimationFrame(loop); if (!renderer || !open) return; // orbit the camera around the look-target: drag yaw/pitch reveal the depth; never swing behind the sheet. const cx = target.x + dist * Math.sin(yaw) * Math.cos(pitch); const cy = target.y + dist * Math.sin(pitch); const cz = dist * Math.cos(yaw) * Math.cos(pitch); camera.position.set(cx, cy, cz); camera.lookAt(target); renderer.render(scene, camera); } function closeFn() { open = false; cancelAnimationFrame(raf); if (hintTimer) { clearTimeout(hintTimer); hintTimer = 0; } hintEl = null; window.removeEventListener("resize", resize); document.removeEventListener("keydown", onKey); if (_onWinMove) { window.removeEventListener("mousemove", _onWinMove); _onWinMove = null; } // no leak per open/close if (_onWinUp) { window.removeEventListener("mouseup", _onWinUp); _onWinUp = null; } try { if (mesh) { mesh.geometry.dispose(); } } catch (_) {} try { if (backdrop) { backdrop.geometry.dispose(); backdrop.material.dispose(); } } catch (_) {} try { if (mat) { for (const k of ["uImg", "uDep"]) { const t = mat.uniforms[k] && mat.uniforms[k].value; if (t && t.dispose) t.dispose(); } mat.dispose(); } } catch (_) {} try { if (renderer) renderer.dispose(); } catch (_) {} try { if (host && host.parentNode) host.parentNode.removeChild(host); } catch (_) {} host = renderer = scene = camera = mesh = mat = backdrop = null; const sb = document.getElementById("save-btn"); if (sb) { try { sb.focus(); } catch (_) {} } } return { open: openFn, close: closeFn, get isOpen() { return open; } }; })(); LL.explore3d = Explore3D; export default LL.explore3d;