Download index.html from Mattral/monuments: direct link, hf CLI and curl.
- Browser
- Download file 24.5 kB
-
https://huggingface.co/spaces/Mattral/monuments/resolve/main/index.html
- Command line
-
hf download hf://spaces/Mattral/monuments/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Mattral/monuments/resolve/main/index.html
24.5 kB
| <html lang="fr"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> | |
| <title>Monuments de Paris — Splat Captures</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@300;400;500&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --ink:#0b0a09; | |
| --paper:#ece4d6; | |
| --paper-dim:#9a9286; | |
| --gold:#c9a96a; | |
| --gold-bright:#e6c98a; | |
| --line:rgba(201,169,106,.28); | |
| } | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| html,body{height:100%;overflow:hidden;background:var(--ink);color:var(--paper)} | |
| body{ | |
| font-family:"IBM Plex Mono",monospace; | |
| -webkit-font-smoothing:antialiased; | |
| position:fixed;inset:0; | |
| touch-action:none; | |
| } | |
| /* atmospheric background */ | |
| #bg{position:absolute;inset:0;z-index:0; | |
| background: | |
| radial-gradient(120% 90% at 50% 38%, rgba(80,64,38,.20), rgba(11,10,9,0) 55%), | |
| radial-gradient(140% 120% at 50% 120%, rgba(201,169,106,.06), rgba(11,10,9,0) 50%), | |
| #0b0a09; | |
| } | |
| /* the splat viewer injects its own canvas here */ | |
| #viewer{position:absolute;inset:0;z-index:1} | |
| #viewer canvas{display:block} | |
| /* grain + vignette */ | |
| #vignette{position:absolute;inset:0;z-index:2;pointer-events:none; | |
| background:radial-gradient(120% 100% at 50% 45%, rgba(0,0,0,0) 45%, rgba(0,0,0,.6) 100%);} | |
| #grain{position:absolute;inset:-50%;z-index:3;pointer-events:none;opacity:.045; | |
| background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); | |
| animation:grain 6s steps(6) infinite;} | |
| @keyframes grain{0%{transform:translate(0,0)}50%{transform:translate(-3%,2%)}100%{transform:translate(2%,-2%)}} | |
| .ui{position:absolute;z-index:4} | |
| /* top bar */ | |
| .topbar{top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-start; | |
| padding:22px 26px;pointer-events:none;} | |
| .brand{display:flex;align-items:center;gap:10px;font-size:11px;letter-spacing:.28em;color:var(--paper-dim);text-transform:uppercase;} | |
| .dot{width:6px;height:6px;border-radius:50%;background:var(--gold);box-shadow:0 0 10px var(--gold);animation:pulse 2.4s ease-in-out infinite} | |
| @keyframes pulse{0%,100%{opacity:.4}50%{opacity:1}} | |
| .topright{text-align:right;font-size:11px;letter-spacing:.18em;color:var(--paper-dim);pointer-events:auto} | |
| .galnav{display:flex;gap:14px;font-size:11px;letter-spacing:.2em;text-transform:uppercase} | |
| .galnav a{color:var(--paper-dim);text-decoration:none;transition:.25s} | |
| .galnav a:hover{color:var(--gold-bright)} | |
| .galnav a.gactive{color:var(--gold);border-bottom:1px solid var(--gold);padding-bottom:2px;pointer-events:none} | |
| .brand-col{display:flex;flex-direction:column;gap:7px;pointer-events:none} | |
| .agentlink{pointer-events:auto;width:max-content;font-size:10px;letter-spacing:.16em; | |
| color:var(--gold);text-transform:uppercase;text-decoration:none; | |
| border-bottom:1px solid var(--line);padding-bottom:2px;transition:.25s} | |
| .agentlink:hover{color:var(--gold-bright);border-color:var(--gold)} | |
| .toggle{cursor:pointer;border:1px solid var(--line);padding:7px 12px;border-radius:2px; | |
| color:var(--paper);background:rgba(201,169,106,.04);transition:.25s;user-select:none} | |
| .toggle:hover{border-color:var(--gold);color:var(--gold-bright)} | |
| .toggle.off{opacity:.45} | |
| /* title */ | |
| .title{top:60px;left:0;right:0;text-align:center;pointer-events:none} | |
| .title .kick{font-size:11px;letter-spacing:.5em;color:var(--gold);text-transform:uppercase;margin-bottom:6px} | |
| .title h1{font-family:"Cormorant Garamond",serif;font-weight:500;font-size:clamp(34px,6vw,68px); | |
| line-height:.95;letter-spacing:.01em} | |
| .title h1 em{font-style:italic;color:var(--gold-bright)} | |
| .rule{width:54px;height:1px;background:var(--gold);margin:14px auto 0;opacity:.6} | |
| /* info panel */ | |
| .info{left:26px;bottom:150px;max-width:340px;pointer-events:none; | |
| opacity:0;transform:translateY(8px);transition:opacity .6s,transform .6s} | |
| .info.show{opacity:1;transform:none} | |
| .info .now{font-size:10px;letter-spacing:.3em;color:var(--paper-dim);text-transform:uppercase} | |
| .info .name{font-family:"Cormorant Garamond",serif;font-weight:500;font-size:clamp(28px,5vw,46px); | |
| line-height:1.02;margin:6px 0 4px} | |
| .info .loc{font-size:11px;letter-spacing:.14em;color:var(--gold)} | |
| .info .line{font-family:"Cormorant Garamond",serif;font-style:italic;font-size:18px;color:var(--paper-dim);margin-top:12px;line-height:1.3} | |
| .info .stats{margin-top:14px;font-size:10.5px;letter-spacing:.1em;color:var(--paper-dim); | |
| border-top:1px solid var(--line);padding-top:10px} | |
| .info .stats b{color:var(--paper);font-weight:400} | |
| /* card rail */ | |
| .rail{left:0;right:0;bottom:0;display:flex;gap:10px;padding:18px 26px 22px; | |
| overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch} | |
| .rail::-webkit-scrollbar{display:none} | |
| .card{flex:0 0 auto;min-width:128px;cursor:pointer;padding:12px 14px;border:1px solid rgba(255,255,255,.07); | |
| border-radius:3px;background:rgba(255,255,255,.015);transition:.3s;backdrop-filter:blur(4px)} | |
| .card:hover{border-color:var(--line);background:rgba(201,169,106,.05)} | |
| .card.active{border-color:var(--gold);background:rgba(201,169,106,.09)} | |
| .card .idx{font-size:10px;letter-spacing:.2em;color:var(--gold)} | |
| .card .cn{font-family:"Cormorant Garamond",serif;font-size:20px;line-height:1.05;margin:4px 0 5px} | |
| .card .meta{font-size:9.5px;letter-spacing:.08em;color:var(--paper-dim)} | |
| .card.active .meta{color:var(--gold)} | |
| /* hint */ | |
| .hint{left:0;right:0;bottom:128px;text-align:center;font-size:10.5px;letter-spacing:.28em; | |
| color:var(--paper-dim);text-transform:uppercase;pointer-events:none;transition:opacity .8s} | |
| .hint.hide{opacity:0} | |
| /* loader */ | |
| #loader{position:absolute;inset:0;z-index:9;display:flex;align-items:center;justify-content:center; | |
| background:var(--ink);transition:opacity .8s;flex-direction:column;gap:14px} | |
| #loader.gone{opacity:0;pointer-events:none} | |
| #loader .l{font-size:11px;letter-spacing:.4em;color:var(--gold);text-transform:uppercase} | |
| #loader .bar{width:120px;height:1px;background:rgba(201,169,106,.2);overflow:hidden;position:relative} | |
| #loader .bar i{display:block;height:100%;width:40%;background:var(--gold);position:absolute;left:0; | |
| animation:slide 1.1s ease-in-out infinite} | |
| #loader.determinate .bar i{animation:none;width:var(--p,0%);transition:width .3s} | |
| @keyframes slide{0%{transform:translateX(-120%)}100%{transform:translateX(330%)}} | |
| #loader .pct{font-size:10px;letter-spacing:.2em;color:var(--paper-dim)} | |
| @media(max-width:720px){ | |
| .title{top:54px} | |
| .info{bottom:140px;left:18px;right:18px;max-width:none;text-align:center} | |
| .info .stats{display:inline-block} | |
| .info .line{display:none} | |
| .topbar{padding:16px 18px} | |
| .rail{padding:14px 18px 18px} | |
| .hint{bottom:120px} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="bg"></div> | |
| <div id="viewer"></div> | |
| <div id="vignette"></div> | |
| <div id="grain"></div> | |
| <div class="ui topbar"> | |
| <div class="brand-col"> | |
| <div class="brand"><span class="dot"></span>Gaussian Splat Capture</div> | |
| <a class="agentlink" href="https://huggingface.co/spaces/mishig/monuments-de-paris/blob/main/README.md" target="_blank" rel="noopener">↗ Built with an agent — make your own</a> | |
| </div> | |
| <nav class="topright galnav"> | |
| <a href="https://mishig-monuments-de-paris.static.hf.space" class="gactive">Paris</a> | |
| <a href="https://mishig-monuments-of-japan.static.hf.space">Japon</a> | |
| <a href="https://mishig-monuments-of-egypt.static.hf.space">Égypte</a> | |
| </nav> | |
| </div> | |
| <div class="ui title"> | |
| <div class="kick">Collection · Île-de-France</div> | |
| <h1>Monuments de <em>Paris</em></h1> | |
| <div class="rule"></div> | |
| </div> | |
| <div class="ui info" id="info"> | |
| <div class="now">En vue</div> | |
| <div class="name" id="i-name">—</div> | |
| <div class="loc" id="i-loc">—</div> | |
| <div class="line" id="i-line">—</div> | |
| <div class="stats" id="i-stats">—</div> | |
| </div> | |
| <div class="ui hint" id="hint">Glissez pour pivoter · molette pour changer de monument</div> | |
| <div class="ui rail" id="rail"></div> | |
| <div id="loader"><div class="l">Reconstruction</div><div class="bar"><i></i></div><div class="pct" id="pct"></div></div> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js", | |
| "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/", | |
| "@mkkellogg/gaussian-splats-3d": "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.6/build/gaussian-splats-3d.module.js" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import * as GS from '@mkkellogg/gaussian-splats-3d'; | |
| /* ============================================================================ | |
| Real gaussian-splat captures, rendered with @mkkellogg/gaussian-splats-3d. | |
| Each monument is a .ply produced by TripoSplat from an Ideogram-generated, | |
| black-background specimen image. To swap a capture, just drop a new file in | |
| splats/ and update its entry in MON below. | |
| ========================================================================== */ | |
| const MON=[ | |
| {file:"ksplat/eiffel-square.ksplat", name:"Tour Eiffel", loc:"Champ-de-Mars · 7ᵉ", | |
| line:"La dame de fer sur le tapis vert du Champ-de-Mars.", stats:"524K splats · TripoSplat · 2026"}, | |
| {file:"ksplat/opera-garnier.ksplat", name:"Opéra Garnier", loc:"Place de l'Opéra · 9ᵉ", | |
| line:"Faste Second Empire sous le dôme de cuivre.", stats:"262K splats · TripoSplat · 2026"}, | |
| {file:"ksplat/arc-de-triomphe.ksplat",name:"Arc de Triomphe", loc:"Place Charles-de-Gaulle · 8ᵉ", | |
| line:"Pierre et mémoire au bout des Champs-Élysées.", stats:"262K splats · TripoSplat · 2026"}, | |
| {file:"ksplat/sacre-coeur.ksplat", name:"Sacré-Cœur", loc:"Butte Montmartre · 18ᵉ", | |
| line:"Travertin blanc veillant sur la ville.", stats:"262K splats · TripoSplat · 2026"}, | |
| {file:"ksplat/moulin-rouge.ksplat", name:"Moulin Rouge", loc:"Boulevard de Clichy · 18ᵉ", | |
| line:"Le moulin écarlate qui ne tourne que la nuit.", stats:"524K splats · TripoSplat · 2026"}, | |
| {file:"ksplat/pantheon.ksplat", name:"Panthéon", loc:"Montagne Sainte-Geneviève · 5ᵉ", | |
| line:"Aux grands hommes, la patrie reconnaissante.", stats:"262K splats · TripoSplat · 2026"}, | |
| ]; | |
| const viewerEl = document.getElementById('viewer'); | |
| const loader = document.getElementById('loader'); | |
| const pctEl = document.getElementById('pct'); | |
| const info = document.getElementById('info'); | |
| const hint = document.getElementById('hint'); | |
| /* ---- shared three.js camera + renderer, driven by our own orbit controls ---- */ | |
| const renderer = new THREE.WebGLRenderer({antialias:false, alpha:true}); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio,2)); | |
| renderer.setClearColor(0x000000,0); | |
| viewerEl.appendChild(renderer.domElement); | |
| const camera = new THREE.PerspectiveCamera(45, innerWidth/innerHeight, 0.1, 500); | |
| /* one Viewer, self-managed render loop turned OFF so we drive it ourselves */ | |
| let viewer = null; | |
| let current = -1, switching = false; | |
| function makeViewer(){ | |
| return new GS.Viewer({ | |
| renderer, | |
| camera, | |
| useBuiltInControls:false, // we supply our own orbit/touch controls | |
| selfDrivenMode:false, // we call viewer.update()/render() in our loop | |
| sharedMemoryForWorkers:false, // works from file:// and simple static hosts | |
| gpuAcceleratedSort:false, // CPU sort in a worker — robust across GPUs/software-WebGL | |
| sphericalHarmonicsDegree:0, | |
| antialiased:true, | |
| ignoreDevicePixelRatio:false, | |
| dynamicScene:false, | |
| }); | |
| } | |
| /* ============================================================================ | |
| HORIZONTAL SLIDE TRANSITION | |
| The outgoing monument is kept alive in a second viewer and slides off one side | |
| while the incoming monument slides in from the other. Each is rendered with its | |
| OWN framing (camera pan along screen-right), so they cross cleanly — no gap. | |
| While the incoming splat loads, the outgoing one stays centered (and rotating), | |
| then they cross once it's ready. | |
| ========================================================================== */ | |
| let prevViewer=null; // outgoing splat, alive during the slide | |
| let prevTarget=new THREE.Vector3(), prevRadius=3; | |
| let tPhase='idle', tStart=0, navDir=1; // navDir: +1 = next (exit left), -1 = prev | |
| let slideA=0, slideB=0; // camera pan (world units) for prev / current | |
| let slideDist=4, showCurrent=true; // showCurrent: render the incoming viewer yet? | |
| const SLIDE_DUR=520; | |
| const easeIO=t=>t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2; | |
| function slideSpan(rad){ // pan needed to clear the frame horizontally | |
| const fov=THREE.MathUtils.degToRad(camera.fov); | |
| return rad*Math.tan(fov*0.5)*Math.max(1,camera.aspect) + rad*0.7; | |
| } | |
| function updateTransition(now){ | |
| if(tPhase!=='slide') return; | |
| let p=(now-tStart)/SLIDE_DUR; if(p>=1)p=1; | |
| const e=easeIO(p); | |
| slideA = e * ( slideDist*navDir); // outgoing pans out (exits) | |
| slideB = (1-e) * (-slideDist*navDir); // incoming pans in from the opposite side | |
| if(p>=1){ | |
| tPhase='idle'; slideA=0; slideB=0; | |
| if(prevViewer){ const pv=prevViewer; prevViewer=null; try{ pv.dispose(); }catch(e){} } | |
| info.classList.add('show'); | |
| } | |
| } | |
| async function switchTo(i, dir){ | |
| if(switching || i===current) return; | |
| switching = true; | |
| const prev = current; | |
| current = i; | |
| const m = MON[i]; | |
| navDir = (dir!==undefined) ? dir : (prev<0 ? 1 : (i>prev ? 1 : -1)); | |
| // UI swap (the info panel stays hidden until the incoming monument lands) | |
| document.getElementById('i-name').textContent = m.name; | |
| document.getElementById('i-loc').textContent = m.loc; | |
| document.getElementById('i-line').textContent = '« '+m.line+' »'; | |
| document.getElementById('i-stats').innerHTML = '<b>'+m.stats.split(' · ').join('</b> · <b>')+'</b>'; | |
| info.classList.remove('show'); | |
| [...rail.children].forEach((c,j)=>c.classList.toggle('active', j===i)); | |
| centerActiveCard(i); | |
| // keep the OUTGOING splat alive so it can slide out; it stays centered while we load | |
| if(viewer && prev!==-1){ prevViewer = viewer; prevTarget.copy(target); prevRadius = radius; } | |
| else { prevViewer = null; } | |
| viewer = null; showCurrent = false; tPhase = 'idle'; slideA = 0; slideB = 0; | |
| // first ever load shows the progress loader; later switches keep the old one on screen | |
| if(prev===-1){ loader.classList.remove('gone'); loader.classList.add('determinate'); setProgress(0); } | |
| else { loader.classList.add('gone'); } | |
| // build + load the incoming splat (hidden until loaded), then start the cross-slide | |
| const v = makeViewer(); | |
| try{ | |
| await v.addSplatScene(m.file, { | |
| showLoadingUI:false, | |
| progressiveLoad:false, // resolves only when fully built ⇒ correct framing first try | |
| splatAlphaRemovalThreshold:8, // drop near-transparent gaussians (clean black bg) | |
| rotation:[1,0,0,0], // 180° about X: TripoSplat is Y-down, flip upright | |
| onProgress:(pct)=>setProgress(pct), | |
| }); | |
| viewer = v; window.__viewer = viewer; window.__THREE = THREE; | |
| frameObject(); // incoming monument's own framing (target/radius) | |
| slideDist = Math.max(slideSpan(prevRadius), slideSpan(radius)); | |
| slideB = -slideDist*navDir; // start the incoming off the entry side | |
| showCurrent = true; | |
| if(prev===-1){ loader.classList.add('gone'); prefetchRest(); } // warm the cache for the others | |
| tStart = performance.now(); tPhase = 'slide'; | |
| }catch(err){ | |
| console.error('Failed to load', m.file, err); | |
| loader.classList.remove('gone'); pctEl.textContent='échec du chargement'; | |
| if(!viewer){ viewer = prevViewer; prevViewer = null; } // fall back to the old one | |
| }finally{ | |
| switching = false; | |
| resetIdle(); | |
| } | |
| } | |
| function setProgress(pct){ | |
| pct = Math.max(0, Math.min(100, Math.round(pct||0))); | |
| loader.style.setProperty('--p', pct+'%'); | |
| pctEl.textContent = pct+'%'; | |
| } | |
| /* ---- orbit / touch controls (frame-independent, our own) ---- */ | |
| let theta=0.6, phi=1.15, radius=4.2, target=new THREE.Vector3(0,0,0); | |
| let lastInteract=0, autoRotate=true; | |
| const pointers=new Map(); let lastPinch=0; | |
| /* Per-monument framing. Each monument's geometry (center + extents) is sampled | |
| once and cached; the camera distance is then derived from that geometry AND the | |
| current viewport, so sizing/positioning re-adapt on resize / rotate / DPI change. | |
| Monuments do NOT share a camera — each keeps its own target + radius. */ | |
| const FRAME_MARGIN = 2.15; // >1 = breathing room around the monument | |
| const frameCache = {}; // index -> {cx,cy,cz,h,wDiag} | |
| function sampleBounds(){ | |
| const xs=[], ys=[], zs=[]; let sx=0, sz=0; | |
| try{ | |
| const mesh = viewer.splatMesh; | |
| const count = mesh.getSplatCount(); | |
| const step = Math.max(1, Math.floor(count/20000)); // dense: don't under-sample thin parts | |
| const c = new THREE.Vector3(); | |
| for(let i=0;i<count;i+=step){ | |
| mesh.getSplatCenter(i, c, true); | |
| xs.push(c.x); ys.push(c.y); zs.push(c.z); sx+=c.x; sz+=c.z; | |
| } | |
| }catch(e){} | |
| if(xs.length>10){ | |
| const n = xs.length; | |
| const pct=(arr,p)=>{const a=arr.slice().sort((u,v)=>u-v); | |
| return a[Math.min(a.length-1,Math.max(0,Math.round(p*(a.length-1))))];}; | |
| // robust extents ignore stray floater gaussians that otherwise skew the bbox | |
| const p1x=pct(xs,.01),p99x=pct(xs,.99),p1z=pct(zs,.01),p99z=pct(zs,.99), | |
| p1y=pct(ys,.005),p99y=pct(ys,.995); | |
| frameCache[current] = { | |
| // horizontal pivot = mean (mass is symmetric about the vertical axis), so the | |
| // monument spins in place; robust to floaters that wreck a raw bbox center | |
| cx: sx/n, | |
| cz: sz/n, | |
| cy: (p1y+p99y)/2, // robust vertical center for framing | |
| h: p99y-p1y, | |
| // widest horizontal extent across ALL azimuths is the footprint diagonal | |
| wDiag: Math.hypot(p99x-p1x, p99z-p1z), | |
| }; | |
| } | |
| } | |
| /* derive target + radius from cached geometry and the CURRENT viewport */ | |
| function applyFraming(){ | |
| const f = frameCache[current]; | |
| if(!f){ target.set(0,0,0); radius=3; minR=1.2; maxR=8; return; } | |
| target.set(f.cx, f.cy, f.cz); | |
| const fov = THREE.MathUtils.degToRad(camera.fov); | |
| const fitH = (f.h*0.5)/Math.tan(fov*0.5); | |
| const fitW = (f.wDiag*0.5)/Math.tan(fov*0.5)/camera.aspect; // aspect-aware → adapts to screen | |
| radius = Math.max(fitH, fitW) * FRAME_MARGIN + 0.05; | |
| minR = radius*0.4; maxR = radius*2.6; | |
| } | |
| function frameObject(){ sampleBounds(); applyFraming(); } | |
| let minR=2, maxR=10; | |
| const _right=new THREE.Vector3(); | |
| function applyCamFor(tgt, rad, pan){ | |
| const sp=Math.sin(phi); | |
| camera.position.set( | |
| tgt.x+rad*sp*Math.sin(theta), | |
| tgt.y+rad*Math.cos(phi), | |
| tgt.z+rad*sp*Math.cos(theta)); | |
| camera.lookAt(tgt); | |
| if(pan){ | |
| // pan along the camera's right axis so the monument slides horizontally on screen | |
| _right.set(1,0,0).applyQuaternion(camera.quaternion); | |
| camera.position.addScaledVector(_right, pan); | |
| camera.lookAt(tgt.x+_right.x*pan, tgt.y+_right.y*pan, tgt.z+_right.z*pan); | |
| } | |
| camera.updateMatrixWorld(); | |
| } | |
| const dom = renderer.domElement; | |
| dom.addEventListener('pointerdown',e=>{pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});resetIdle();hideHint();}); | |
| dom.addEventListener('pointermove',e=>{ | |
| if(!pointers.has(e.pointerId))return;const p=pointers.get(e.pointerId); | |
| // drag = orbit only (rotate the monument); no zoom | |
| theta-=(e.clientX-p.x)*0.006; phi-=(e.clientY-p.y)*0.006; | |
| phi=Math.max(0.18,Math.min(Math.PI-0.18,phi)); | |
| pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});resetIdle(); | |
| }); | |
| function endPtr(e){pointers.delete(e.pointerId);resetIdle();} | |
| dom.addEventListener('pointerup',endPtr);dom.addEventListener('pointercancel',endPtr); | |
| function resetIdle(){lastInteract=performance.now();} | |
| /* ---- scroll / keys = move BETWEEN monuments (no zoom) ---- */ | |
| function navigate(dir){ | |
| if(switching) return; | |
| const n=(current+dir+MON.length)%MON.length; | |
| if(n!==current) switchTo(n, dir); | |
| } | |
| let wheelAccum=0; | |
| dom.addEventListener('wheel',e=>{ | |
| e.preventDefault(); hideHint(); resetIdle(); | |
| if(switching){ wheelAccum=0; return; } // one monument per gesture | |
| wheelAccum += e.deltaY; | |
| if(Math.abs(wheelAccum) >= 120){ navigate(wheelAccum>0?1:-1); wheelAccum=0; } // needs a longer scroll | |
| },{passive:false}); | |
| addEventListener('keydown',e=>{ | |
| if(['ArrowDown','ArrowRight','PageDown',' '].includes(e.key)){ e.preventDefault(); navigate(1); hideHint(); } | |
| else if(['ArrowUp','ArrowLeft','PageUp'].includes(e.key)){ e.preventDefault(); navigate(-1); hideHint(); } | |
| }); | |
| let hinted=false; | |
| function hideHint(){if(!hinted){hinted=true;hint.classList.add('hide');}} | |
| /* ---- card rail ---- */ | |
| /* warm the browser cache: quietly download the other monuments' splats in the | |
| background after the first one is up, so later transitions are instant (the | |
| files are immutable + cache-forever, so the viewer's loader reuses them) */ | |
| let prefetched=false; | |
| function prefetchRest(){ | |
| if(prefetched) return; prefetched=true; | |
| const others = MON.map(m=>m.file).filter((_,j)=>j!==current); | |
| let k=0; | |
| (function next(){ | |
| if(k>=others.length) return; | |
| fetch(others[k++], {cache:'force-cache'}).catch(()=>{}).finally(()=>setTimeout(next, 250)); | |
| })(); | |
| } | |
| const rail=document.getElementById('rail'); | |
| MON.forEach((m,i)=>{const d=document.createElement('div');d.className='card'; | |
| d.innerHTML='<div class="idx">'+String(i+1).padStart(2,'0')+'</div><div class="cn">'+m.name+'</div><div class="meta">'+m.loc+'</div>'; | |
| d.addEventListener('click',()=>switchTo(i));rail.appendChild(d);}); | |
| /* center the active card in the rail when possible; clamp so the first/last | |
| cards (e.g. Panthéon) never scroll off-screen */ | |
| function centerActiveCard(i){ | |
| const c=rail.children[i]; if(!c) return; | |
| const left = c.offsetLeft - (rail.clientWidth - c.clientWidth)/2; | |
| const max = rail.scrollWidth - rail.clientWidth; | |
| rail.scrollTo({left: Math.max(0, Math.min(left, max)), behavior:'smooth'}); | |
| } | |
| /* ---- resize ---- */ | |
| function resize(){ | |
| const w=innerWidth,h=innerHeight; | |
| renderer.setPixelRatio(Math.min(devicePixelRatio,2)); // adapt to DPI / monitor change | |
| renderer.setSize(w,h); | |
| camera.aspect=w/h; camera.updateProjectionMatrix(); | |
| applyFraming(); // re-fit the current monument to the new viewport | |
| } | |
| addEventListener('resize',resize); | |
| addEventListener('orientationchange',resize); | |
| resize(); | |
| /* ---- render loop ---- */ | |
| renderer.autoClear = false; // we composite the two viewers ourselves | |
| function loop(now){ | |
| requestAnimationFrame(loop); | |
| // rotate immediately and throughout; only pause while the user is actively dragging | |
| if(autoRotate && pointers.size===0) theta+=0.0016; | |
| updateTransition(now); | |
| renderer.clear(); | |
| // outgoing monument (during a slide) — its own framing, panned out | |
| if(prevViewer){ | |
| applyCamFor(prevTarget, prevRadius, slideA); | |
| try{ prevViewer.update(); prevViewer.render(); }catch(e){} | |
| } | |
| // incoming / current monument — its own framing, panned in | |
| if(viewer && showCurrent){ | |
| applyCamFor(target, radius, slideB); | |
| try{ viewer.update(); viewer.render(); }catch(e){} | |
| } | |
| } | |
| requestAnimationFrame(loop); | |
| window.__phase=()=>tPhase; | |
| window.__lock=()=>{}; window.__unlock=()=>{}; // no-ops (kept so older test scripts don't break) | |
| window.__idx=()=>current; window.__radius=()=>+radius.toFixed(3); window.__nav=(d)=>navigate(d); | |
| window.__theta=()=>+theta.toFixed(4); | |
| window.__slide=()=>[+slideA.toFixed(3),+slideB.toFixed(3)]; | |
| window.__dbg=()=>({ph:tPhase, prev:!!prevViewer, show:showCurrent, cur:!!viewer}); | |
| window.__setView=(t,ph)=>{autoRotate=false;theta=t;if(ph!==undefined)phi=ph;}; | |
| window.__target=()=>[+target.x.toFixed(3),+target.y.toFixed(3),+target.z.toFixed(3)]; | |
| /* ---- kickoff ---- */ | |
| switchTo(0); | |
| </script> | |
| </body> | |
| </html> | |