monuments / index.html
mishig's picture
mishig HF Staff
Upload index.html with huggingface_hub
53a637b verified
Raw
History Blame Contribute Delete
24.5 kB
<!DOCTYPE html>
<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>