Upload index.html with huggingface_hub
Browse files- index.html +484 -18
index.html
CHANGED
|
@@ -1,19 +1,485 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|