mishig HF Staff commited on
Commit
a17fdd6
·
verified ·
1 Parent(s): b956c8c

Upload index.html with huggingface_hub

Browse files
Files changed (1) hide show
  1. index.html +484 -18
index.html CHANGED
@@ -1,19 +1,485 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
6
+ <title>Monuments de Paris — Splat Captures</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <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">
10
+ <style>
11
+ :root{
12
+ --ink:#0b0a09;
13
+ --paper:#ece4d6;
14
+ --paper-dim:#9a9286;
15
+ --gold:#c9a96a;
16
+ --gold-bright:#e6c98a;
17
+ --line:rgba(201,169,106,.28);
18
+ }
19
+ *{margin:0;padding:0;box-sizing:border-box}
20
+ html,body{height:100%;overflow:hidden;background:var(--ink);color:var(--paper)}
21
+ body{
22
+ font-family:"IBM Plex Mono",monospace;
23
+ -webkit-font-smoothing:antialiased;
24
+ position:fixed;inset:0;
25
+ touch-action:none;
26
+ }
27
+ /* atmospheric background */
28
+ #bg{position:absolute;inset:0;z-index:0;
29
+ background:
30
+ radial-gradient(120% 90% at 50% 38%, rgba(80,64,38,.20), rgba(11,10,9,0) 55%),
31
+ radial-gradient(140% 120% at 50% 120%, rgba(201,169,106,.06), rgba(11,10,9,0) 50%),
32
+ #0b0a09;
33
+ }
34
+ /* the splat viewer injects its own canvas here */
35
+ #viewer{position:absolute;inset:0;z-index:1}
36
+ #viewer canvas{display:block}
37
+ /* grain + vignette */
38
+ #vignette{position:absolute;inset:0;z-index:2;pointer-events:none;
39
+ background:radial-gradient(120% 100% at 50% 45%, rgba(0,0,0,0) 45%, rgba(0,0,0,.6) 100%);}
40
+ #grain{position:absolute;inset:-50%;z-index:3;pointer-events:none;opacity:.045;
41
+ 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");
42
+ animation:grain 6s steps(6) infinite;}
43
+ @keyframes grain{0%{transform:translate(0,0)}50%{transform:translate(-3%,2%)}100%{transform:translate(2%,-2%)}}
44
+
45
+ .ui{position:absolute;z-index:4}
46
+ /* top bar */
47
+ .topbar{top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-start;
48
+ padding:22px 26px;pointer-events:none;}
49
+ .brand{display:flex;align-items:center;gap:10px;font-size:11px;letter-spacing:.28em;color:var(--paper-dim);text-transform:uppercase;}
50
+ .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}
51
+ @keyframes pulse{0%,100%{opacity:.4}50%{opacity:1}}
52
+ .topright{text-align:right;font-size:11px;letter-spacing:.18em;color:var(--paper-dim);pointer-events:auto}
53
+ .toggle{cursor:pointer;border:1px solid var(--line);padding:7px 12px;border-radius:2px;
54
+ color:var(--paper);background:rgba(201,169,106,.04);transition:.25s;user-select:none}
55
+ .toggle:hover{border-color:var(--gold);color:var(--gold-bright)}
56
+ .toggle.off{opacity:.45}
57
+
58
+ /* title */
59
+ .title{top:60px;left:0;right:0;text-align:center;pointer-events:none}
60
+ .title .kick{font-size:11px;letter-spacing:.5em;color:var(--gold);text-transform:uppercase;margin-bottom:6px}
61
+ .title h1{font-family:"Cormorant Garamond",serif;font-weight:500;font-size:clamp(34px,6vw,68px);
62
+ line-height:.95;letter-spacing:.01em}
63
+ .title h1 em{font-style:italic;color:var(--gold-bright)}
64
+ .rule{width:54px;height:1px;background:var(--gold);margin:14px auto 0;opacity:.6}
65
+
66
+ /* info panel */
67
+ .info{left:26px;bottom:150px;max-width:340px;pointer-events:none;
68
+ opacity:0;transform:translateY(8px);transition:opacity .6s,transform .6s}
69
+ .info.show{opacity:1;transform:none}
70
+ .info .now{font-size:10px;letter-spacing:.3em;color:var(--paper-dim);text-transform:uppercase}
71
+ .info .name{font-family:"Cormorant Garamond",serif;font-weight:500;font-size:clamp(28px,5vw,46px);
72
+ line-height:1.02;margin:6px 0 4px}
73
+ .info .loc{font-size:11px;letter-spacing:.14em;color:var(--gold)}
74
+ .info .line{font-family:"Cormorant Garamond",serif;font-style:italic;font-size:18px;color:var(--paper-dim);margin-top:12px;line-height:1.3}
75
+ .info .stats{margin-top:14px;font-size:10.5px;letter-spacing:.1em;color:var(--paper-dim);
76
+ border-top:1px solid var(--line);padding-top:10px}
77
+ .info .stats b{color:var(--paper);font-weight:400}
78
+
79
+ /* card rail */
80
+ .rail{left:0;right:0;bottom:0;display:flex;gap:10px;padding:18px 26px 22px;
81
+ overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch}
82
+ .rail::-webkit-scrollbar{display:none}
83
+ .card{flex:0 0 auto;min-width:128px;cursor:pointer;padding:12px 14px;border:1px solid rgba(255,255,255,.07);
84
+ border-radius:3px;background:rgba(255,255,255,.015);transition:.3s;backdrop-filter:blur(4px)}
85
+ .card:hover{border-color:var(--line);background:rgba(201,169,106,.05)}
86
+ .card.active{border-color:var(--gold);background:rgba(201,169,106,.09)}
87
+ .card .idx{font-size:10px;letter-spacing:.2em;color:var(--gold)}
88
+ .card .cn{font-family:"Cormorant Garamond",serif;font-size:20px;line-height:1.05;margin:4px 0 5px}
89
+ .card .meta{font-size:9.5px;letter-spacing:.08em;color:var(--paper-dim)}
90
+ .card.active .meta{color:var(--gold)}
91
+
92
+ /* hint */
93
+ .hint{left:0;right:0;bottom:128px;text-align:center;font-size:10.5px;letter-spacing:.28em;
94
+ color:var(--paper-dim);text-transform:uppercase;pointer-events:none;transition:opacity .8s}
95
+ .hint.hide{opacity:0}
96
+
97
+ /* loader */
98
+ #loader{position:absolute;inset:0;z-index:9;display:flex;align-items:center;justify-content:center;
99
+ background:var(--ink);transition:opacity .8s;flex-direction:column;gap:14px}
100
+ #loader.gone{opacity:0;pointer-events:none}
101
+ #loader .l{font-size:11px;letter-spacing:.4em;color:var(--gold);text-transform:uppercase}
102
+ #loader .bar{width:120px;height:1px;background:rgba(201,169,106,.2);overflow:hidden;position:relative}
103
+ #loader .bar i{display:block;height:100%;width:40%;background:var(--gold);position:absolute;left:0;
104
+ animation:slide 1.1s ease-in-out infinite}
105
+ #loader.determinate .bar i{animation:none;width:var(--p,0%);transition:width .3s}
106
+ @keyframes slide{0%{transform:translateX(-120%)}100%{transform:translateX(330%)}}
107
+ #loader .pct{font-size:10px;letter-spacing:.2em;color:var(--paper-dim)}
108
+
109
+ @media(max-width:720px){
110
+ .title{top:54px}
111
+ .info{bottom:140px;left:18px;right:18px;max-width:none;text-align:center}
112
+ .info .stats{display:inline-block}
113
+ .info .line{display:none}
114
+ .topbar{padding:16px 18px}
115
+ .rail{padding:14px 18px 18px}
116
+ .hint{bottom:120px}
117
+ }
118
+ </style>
119
+ </head>
120
+ <body>
121
+ <div id="bg"></div>
122
+ <div id="viewer"></div>
123
+ <div id="vignette"></div>
124
+ <div id="grain"></div>
125
+
126
+ <div class="ui topbar">
127
+ <div class="brand"><span class="dot"></span>Gaussian Splat Capture</div>
128
+ <div class="topright"><span id="rot" class="toggle">◐ Rotation auto</span></div>
129
+ </div>
130
+
131
+ <div class="ui title">
132
+ <div class="kick">Collection · Île-de-France</div>
133
+ <h1>Monuments de <em>Paris</em></h1>
134
+ <div class="rule"></div>
135
+ </div>
136
+
137
+ <div class="ui info" id="info">
138
+ <div class="now">En vue</div>
139
+ <div class="name" id="i-name">—</div>
140
+ <div class="loc" id="i-loc">—</div>
141
+ <div class="line" id="i-line">—</div>
142
+ <div class="stats" id="i-stats">—</div>
143
+ </div>
144
+
145
+ <div class="ui hint" id="hint">Glissez pour pivoter · molette pour changer de monument</div>
146
+
147
+ <div class="ui rail" id="rail"></div>
148
+
149
+ <div id="loader"><div class="l">Reconstruction</div><div class="bar"><i></i></div><div class="pct" id="pct"></div></div>
150
+
151
+ <script type="importmap">
152
+ {
153
+ "imports": {
154
+ "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
155
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
156
+ "@mkkellogg/gaussian-splats-3d": "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.6/build/gaussian-splats-3d.module.js"
157
+ }
158
+ }
159
+ </script>
160
+ <script type="module">
161
+ import * as THREE from 'three';
162
+ import * as GS from '@mkkellogg/gaussian-splats-3d';
163
+
164
+ /* ============================================================================
165
+ Real gaussian-splat captures, rendered with @mkkellogg/gaussian-splats-3d.
166
+ Each monument is a .ply produced by TripoSplat from an Ideogram-generated,
167
+ black-background specimen image. To swap a capture, just drop a new file in
168
+ splats/ and update its entry in MON below.
169
+ ========================================================================== */
170
+
171
+ const MON=[
172
+ {file:"splats/eiffel-square.ply", name:"Tour Eiffel", loc:"Champ-de-Mars · 7ᵉ",
173
+ line:"La dame de fer sur le tapis vert du Champ-de-Mars.", stats:"524K splats · TripoSplat · 2026"},
174
+ {file:"splats/opera-garnier.ply", name:"Opéra Garnier", loc:"Place de l'Opéra · 9ᵉ",
175
+ line:"Faste Second Empire sous le dôme de cuivre.", stats:"262K splats · TripoSplat · 2026"},
176
+ {file:"splats/arc-de-triomphe.ply",name:"Arc de Triomphe", loc:"Place Charles-de-Gaulle · 8ᵉ",
177
+ line:"Pierre et mémoire au bout des Champs-Élysées.", stats:"262K splats · TripoSplat · 2026"},
178
+ {file:"splats/sacre-coeur.ply", name:"Sacré-Cœur", loc:"Butte Montmartre · 18ᵉ",
179
+ line:"Travertin blanc veillant sur la ville.", stats:"262K splats · TripoSplat · 2026"},
180
+ {file:"splats/moulin-rouge.ply", name:"Moulin Rouge", loc:"Boulevard de Clichy · 18ᵉ",
181
+ line:"Le moulin écarlate qui ne tourne que la nuit.", stats:"524K splats · TripoSplat · 2026"},
182
+ {file:"splats/pantheon.ply", name:"Panthéon", loc:"Montagne Sainte-Geneviève · 5ᵉ",
183
+ line:"Aux grands hommes, la patrie reconnaissante.", stats:"262K splats · TripoSplat · 2026"},
184
+ ];
185
+
186
+ const viewerEl = document.getElementById('viewer');
187
+ const loader = document.getElementById('loader');
188
+ const pctEl = document.getElementById('pct');
189
+ const info = document.getElementById('info');
190
+ const hint = document.getElementById('hint');
191
+
192
+ /* ---- shared three.js camera + renderer, driven by our own orbit controls ---- */
193
+ const renderer = new THREE.WebGLRenderer({antialias:false, alpha:true});
194
+ renderer.setPixelRatio(Math.min(devicePixelRatio,2));
195
+ renderer.setClearColor(0x000000,0);
196
+ viewerEl.appendChild(renderer.domElement);
197
+
198
+ const camera = new THREE.PerspectiveCamera(45, innerWidth/innerHeight, 0.1, 500);
199
+
200
+ /* one Viewer, self-managed render loop turned OFF so we drive it ourselves */
201
+ let viewer = null;
202
+ let current = -1, switching = false;
203
+
204
+ function makeViewer(){
205
+ return new GS.Viewer({
206
+ renderer,
207
+ camera,
208
+ useBuiltInControls:false, // we supply our own orbit/touch controls
209
+ selfDrivenMode:false, // we call viewer.update()/render() in our loop
210
+ sharedMemoryForWorkers:false, // works from file:// and simple static hosts
211
+ gpuAcceleratedSort:false, // CPU sort in a worker — robust across GPUs/software-WebGL
212
+ sphericalHarmonicsDegree:0,
213
+ antialiased:true,
214
+ ignoreDevicePixelRatio:false,
215
+ dynamicScene:false,
216
+ });
217
+ }
218
+
219
+ /* ============================================================================
220
+ HORIZONTAL SLIDE TRANSITION
221
+ The outgoing monument is kept alive in a second viewer and slides off one side
222
+ while the incoming monument slides in from the other. Each is rendered with its
223
+ OWN framing (camera pan along screen-right), so they cross cleanly — no gap.
224
+ While the incoming splat loads, the outgoing one stays centered (and rotating),
225
+ then they cross once it's ready.
226
+ ========================================================================== */
227
+ let prevViewer=null; // outgoing splat, alive during the slide
228
+ let prevTarget=new THREE.Vector3(), prevRadius=3;
229
+ let tPhase='idle', tStart=0, navDir=1; // navDir: +1 = next (exit left), -1 = prev
230
+ let slideA=0, slideB=0; // camera pan (world units) for prev / current
231
+ let slideDist=4, showCurrent=true; // showCurrent: render the incoming viewer yet?
232
+ const SLIDE_DUR=520;
233
+ const easeIO=t=>t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;
234
+
235
+ function slideSpan(rad){ // pan needed to clear the frame horizontally
236
+ const fov=THREE.MathUtils.degToRad(camera.fov);
237
+ return rad*Math.tan(fov*0.5)*Math.max(1,camera.aspect) + rad*0.7;
238
+ }
239
+
240
+ function updateTransition(now){
241
+ if(tPhase!=='slide') return;
242
+ let p=(now-tStart)/SLIDE_DUR; if(p>=1)p=1;
243
+ const e=easeIO(p);
244
+ slideA = e * ( slideDist*navDir); // outgoing pans out (exits)
245
+ slideB = (1-e) * (-slideDist*navDir); // incoming pans in from the opposite side
246
+ if(p>=1){
247
+ tPhase='idle'; slideA=0; slideB=0;
248
+ if(prevViewer){ const pv=prevViewer; prevViewer=null; try{ pv.dispose(); }catch(e){} }
249
+ info.classList.add('show');
250
+ }
251
+ }
252
+
253
+ async function switchTo(i, dir){
254
+ if(switching || i===current) return;
255
+ switching = true;
256
+ const prev = current;
257
+ current = i;
258
+ const m = MON[i];
259
+ navDir = (dir!==undefined) ? dir : (prev<0 ? 1 : (i>prev ? 1 : -1));
260
+
261
+ // UI swap (the info panel stays hidden until the incoming monument lands)
262
+ document.getElementById('i-name').textContent = m.name;
263
+ document.getElementById('i-loc').textContent = m.loc;
264
+ document.getElementById('i-line').textContent = '« '+m.line+' »';
265
+ document.getElementById('i-stats').innerHTML = '<b>'+m.stats.split(' · ').join('</b> · <b>')+'</b>';
266
+ info.classList.remove('show');
267
+ [...rail.children].forEach((c,j)=>c.classList.toggle('active', j===i));
268
+ centerActiveCard(i);
269
+
270
+ // keep the OUTGOING splat alive so it can slide out; it stays centered while we load
271
+ if(viewer && prev!==-1){ prevViewer = viewer; prevTarget.copy(target); prevRadius = radius; }
272
+ else { prevViewer = null; }
273
+ viewer = null; showCurrent = false; tPhase = 'idle'; slideA = 0; slideB = 0;
274
+
275
+ // first ever load shows the progress loader; later switches keep the old one on screen
276
+ if(prev===-1){ loader.classList.remove('gone'); loader.classList.add('determinate'); setProgress(0); }
277
+ else { loader.classList.add('gone'); }
278
+
279
+ // build + load the incoming splat (hidden until loaded), then start the cross-slide
280
+ const v = makeViewer();
281
+ try{
282
+ await v.addSplatScene(m.file, {
283
+ showLoadingUI:false,
284
+ progressiveLoad:false, // resolves only when fully built ⇒ correct framing first try
285
+ splatAlphaRemovalThreshold:8, // drop near-transparent gaussians (clean black bg)
286
+ rotation:[1,0,0,0], // 180° about X: TripoSplat is Y-down, flip upright
287
+ onProgress:(pct)=>setProgress(pct),
288
+ });
289
+ viewer = v; window.__viewer = viewer; window.__THREE = THREE;
290
+ frameObject(); // incoming monument's own framing (target/radius)
291
+ slideDist = Math.max(slideSpan(prevRadius), slideSpan(radius));
292
+ slideB = -slideDist*navDir; // start the incoming off the entry side
293
+ showCurrent = true;
294
+ if(prev===-1) loader.classList.add('gone');
295
+ tStart = performance.now(); tPhase = 'slide';
296
+ }catch(err){
297
+ console.error('Failed to load', m.file, err);
298
+ loader.classList.remove('gone'); pctEl.textContent='échec du chargement';
299
+ if(!viewer){ viewer = prevViewer; prevViewer = null; } // fall back to the old one
300
+ }finally{
301
+ switching = false;
302
+ resetIdle();
303
+ }
304
+ }
305
+
306
+ function setProgress(pct){
307
+ pct = Math.max(0, Math.min(100, Math.round(pct||0)));
308
+ loader.style.setProperty('--p', pct+'%');
309
+ pctEl.textContent = pct+'%';
310
+ }
311
+
312
+ /* ---- orbit / touch controls (frame-independent, our own) ---- */
313
+ let theta=0.6, phi=1.15, radius=4.2, target=new THREE.Vector3(0,0,0);
314
+ let lastInteract=0, autoRotate=true;
315
+ const pointers=new Map(); let lastPinch=0;
316
+
317
+ /* Per-monument framing. Each monument's geometry (center + extents) is sampled
318
+ once and cached; the camera distance is then derived from that geometry AND the
319
+ current viewport, so sizing/positioning re-adapt on resize / rotate / DPI change.
320
+ Monuments do NOT share a camera — each keeps its own target + radius. */
321
+ const FRAME_MARGIN = 2.15; // >1 = breathing room around the monument
322
+ const frameCache = {}; // index -> {cx,cy,cz,h,wDiag}
323
+
324
+ function sampleBounds(){
325
+ const xs=[], ys=[], zs=[]; let sx=0, sz=0;
326
+ try{
327
+ const mesh = viewer.splatMesh;
328
+ const count = mesh.getSplatCount();
329
+ const step = Math.max(1, Math.floor(count/20000)); // dense: don't under-sample thin parts
330
+ const c = new THREE.Vector3();
331
+ for(let i=0;i<count;i+=step){
332
+ mesh.getSplatCenter(i, c, true);
333
+ xs.push(c.x); ys.push(c.y); zs.push(c.z); sx+=c.x; sz+=c.z;
334
+ }
335
+ }catch(e){}
336
+ if(xs.length>10){
337
+ const n = xs.length;
338
+ const pct=(arr,p)=>{const a=arr.slice().sort((u,v)=>u-v);
339
+ return a[Math.min(a.length-1,Math.max(0,Math.round(p*(a.length-1))))];};
340
+ // robust extents ignore stray floater gaussians that otherwise skew the bbox
341
+ const p1x=pct(xs,.01),p99x=pct(xs,.99),p1z=pct(zs,.01),p99z=pct(zs,.99),
342
+ p1y=pct(ys,.005),p99y=pct(ys,.995);
343
+ frameCache[current] = {
344
+ // horizontal pivot = mean (mass is symmetric about the vertical axis), so the
345
+ // monument spins in place; robust to floaters that wreck a raw bbox center
346
+ cx: sx/n,
347
+ cz: sz/n,
348
+ cy: (p1y+p99y)/2, // robust vertical center for framing
349
+ h: p99y-p1y,
350
+ // widest horizontal extent across ALL azimuths is the footprint diagonal
351
+ wDiag: Math.hypot(p99x-p1x, p99z-p1z),
352
+ };
353
+ }
354
+ }
355
+
356
+ /* derive target + radius from cached geometry and the CURRENT viewport */
357
+ function applyFraming(){
358
+ const f = frameCache[current];
359
+ if(!f){ target.set(0,0,0); radius=3; minR=1.2; maxR=8; return; }
360
+ target.set(f.cx, f.cy, f.cz);
361
+ const fov = THREE.MathUtils.degToRad(camera.fov);
362
+ const fitH = (f.h*0.5)/Math.tan(fov*0.5);
363
+ const fitW = (f.wDiag*0.5)/Math.tan(fov*0.5)/camera.aspect; // aspect-aware → adapts to screen
364
+ radius = Math.max(fitH, fitW) * FRAME_MARGIN + 0.05;
365
+ minR = radius*0.4; maxR = radius*2.6;
366
+ }
367
+
368
+ function frameObject(){ sampleBounds(); applyFraming(); }
369
+ let minR=2, maxR=10;
370
+
371
+
372
+ const _right=new THREE.Vector3();
373
+ function applyCamFor(tgt, rad, pan){
374
+ const sp=Math.sin(phi);
375
+ camera.position.set(
376
+ tgt.x+rad*sp*Math.sin(theta),
377
+ tgt.y+rad*Math.cos(phi),
378
+ tgt.z+rad*sp*Math.cos(theta));
379
+ camera.lookAt(tgt);
380
+ if(pan){
381
+ // pan along the camera's right axis so the monument slides horizontally on screen
382
+ _right.set(1,0,0).applyQuaternion(camera.quaternion);
383
+ camera.position.addScaledVector(_right, pan);
384
+ camera.lookAt(tgt.x+_right.x*pan, tgt.y+_right.y*pan, tgt.z+_right.z*pan);
385
+ }
386
+ camera.updateMatrixWorld();
387
+ }
388
+
389
+ const dom = renderer.domElement;
390
+ dom.addEventListener('pointerdown',e=>{pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});resetIdle();hideHint();});
391
+ dom.addEventListener('pointermove',e=>{
392
+ if(!pointers.has(e.pointerId))return;const p=pointers.get(e.pointerId);
393
+ // drag = orbit only (rotate the monument); no zoom
394
+ theta-=(e.clientX-p.x)*0.006; phi-=(e.clientY-p.y)*0.006;
395
+ phi=Math.max(0.18,Math.min(Math.PI-0.18,phi));
396
+ pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});resetIdle();
397
+ });
398
+ function endPtr(e){pointers.delete(e.pointerId);resetIdle();}
399
+ dom.addEventListener('pointerup',endPtr);dom.addEventListener('pointercancel',endPtr);
400
+ function resetIdle(){lastInteract=performance.now();}
401
+
402
+ /* ---- scroll / keys = move BETWEEN monuments (no zoom) ---- */
403
+ function navigate(dir){
404
+ if(switching) return;
405
+ const n=(current+dir+MON.length)%MON.length;
406
+ if(n!==current) switchTo(n, dir);
407
+ }
408
+ let wheelAccum=0;
409
+ dom.addEventListener('wheel',e=>{
410
+ e.preventDefault(); hideHint(); resetIdle();
411
+ if(switching){ wheelAccum=0; return; } // one monument per gesture
412
+ wheelAccum += e.deltaY;
413
+ if(Math.abs(wheelAccum) >= 120){ navigate(wheelAccum>0?1:-1); wheelAccum=0; } // needs a longer scroll
414
+ },{passive:false});
415
+ addEventListener('keydown',e=>{
416
+ if(['ArrowDown','ArrowRight','PageDown',' '].includes(e.key)){ e.preventDefault(); navigate(1); hideHint(); }
417
+ else if(['ArrowUp','ArrowLeft','PageUp'].includes(e.key)){ e.preventDefault(); navigate(-1); hideHint(); }
418
+ });
419
+
420
+ let hinted=false;
421
+ function hideHint(){if(!hinted){hinted=true;hint.classList.add('hide');}}
422
+
423
+ document.getElementById('rot').addEventListener('click',function(){autoRotate=!autoRotate;this.classList.toggle('off',!autoRotate);});
424
+
425
+ /* ---- card rail ---- */
426
+ const rail=document.getElementById('rail');
427
+ MON.forEach((m,i)=>{const d=document.createElement('div');d.className='card';
428
+ d.innerHTML='<div class="idx">'+String(i+1).padStart(2,'0')+'</div><div class="cn">'+m.name+'</div><div class="meta">'+m.loc+'</div>';
429
+ d.addEventListener('click',()=>switchTo(i));rail.appendChild(d);});
430
+
431
+ /* center the active card in the rail when possible; clamp so the first/last
432
+ cards (e.g. Panthéon) never scroll off-screen */
433
+ function centerActiveCard(i){
434
+ const c=rail.children[i]; if(!c) return;
435
+ const left = c.offsetLeft - (rail.clientWidth - c.clientWidth)/2;
436
+ const max = rail.scrollWidth - rail.clientWidth;
437
+ rail.scrollTo({left: Math.max(0, Math.min(left, max)), behavior:'smooth'});
438
+ }
439
+
440
+ /* ---- resize ---- */
441
+ function resize(){
442
+ const w=innerWidth,h=innerHeight;
443
+ renderer.setPixelRatio(Math.min(devicePixelRatio,2)); // adapt to DPI / monitor change
444
+ renderer.setSize(w,h);
445
+ camera.aspect=w/h; camera.updateProjectionMatrix();
446
+ applyFraming(); // re-fit the current monument to the new viewport
447
+ }
448
+ addEventListener('resize',resize);
449
+ addEventListener('orientationchange',resize);
450
+ resize();
451
+
452
+ /* ---- render loop ---- */
453
+ renderer.autoClear = false; // we composite the two viewers ourselves
454
+ function loop(now){
455
+ requestAnimationFrame(loop);
456
+ // rotate immediately and throughout; only pause while the user is actively dragging
457
+ if(autoRotate && pointers.size===0) theta+=0.0016;
458
+ updateTransition(now);
459
+ renderer.clear();
460
+ // outgoing monument (during a slide) — its own framing, panned out
461
+ if(prevViewer){
462
+ applyCamFor(prevTarget, prevRadius, slideA);
463
+ try{ prevViewer.update(); prevViewer.render(); }catch(e){}
464
+ }
465
+ // incoming / current monument — its own framing, panned in
466
+ if(viewer && showCurrent){
467
+ applyCamFor(target, radius, slideB);
468
+ try{ viewer.update(); viewer.render(); }catch(e){}
469
+ }
470
+ }
471
+ requestAnimationFrame(loop);
472
+ window.__phase=()=>tPhase;
473
+ window.__lock=()=>{}; window.__unlock=()=>{}; // no-ops (kept so older test scripts don't break)
474
+ window.__idx=()=>current; window.__radius=()=>+radius.toFixed(3); window.__nav=(d)=>navigate(d);
475
+ window.__theta=()=>+theta.toFixed(4);
476
+ window.__slide=()=>[+slideA.toFixed(3),+slideB.toFixed(3)];
477
+ window.__dbg=()=>({ph:tPhase, prev:!!prevViewer, show:showCurrent, cur:!!viewer});
478
+ window.__setView=(t,ph)=>{autoRotate=false;theta=t;if(ph!==undefined)phi=ph;};
479
+ window.__target=()=>[+target.x.toFixed(3),+target.y.toFixed(3),+target.z.toFixed(3)];
480
+
481
+ /* ---- kickoff ---- */
482
+ switchTo(0);
483
+ </script>
484
+ </body>
485
  </html>