Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> | |
| <title>Mineral Splats — Borussia Minerals</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #0a0a0f; | |
| --surface: #12121a; | |
| --surface2: #1a1a26; | |
| --border: #2a2a3a; | |
| --text: #e8e8f0; | |
| --text2: #9090a8; | |
| --accent: #7c6fff; | |
| --accent2: #a78bfa; | |
| --card-radius: 12px; | |
| --gap: 16px; | |
| } | |
| html, body { | |
| height: 100%; | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; | |
| -webkit-font-smoothing: antialiased; | |
| } | |
| #landing { | |
| min-height: 100dvh; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| padding: 40px var(--gap) 60px; | |
| } | |
| .brand { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 8px; | |
| margin-bottom: 48px; | |
| text-align: center; | |
| } | |
| .brand-name { | |
| font-size: 13px; | |
| font-weight: 600; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| color: var(--accent2); | |
| } | |
| .brand h1 { | |
| font-size: clamp(26px, 5vw, 40px); | |
| font-weight: 700; | |
| line-height: 1.1; | |
| background: linear-gradient(135deg, #fff 0%, var(--accent2) 100%); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .brand p { | |
| font-size: 15px; | |
| color: var(--text2); | |
| max-width: 480px; | |
| line-height: 1.5; | |
| } | |
| .grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
| gap: var(--gap); | |
| width: 100%; | |
| max-width: 960px; | |
| } | |
| .card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--card-radius); | |
| overflow: hidden; | |
| cursor: pointer; | |
| transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; | |
| text-decoration: none; | |
| color: inherit; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .card:hover { | |
| transform: translateY(-3px); | |
| border-color: var(--accent); | |
| box-shadow: 0 8px 32px rgba(124,111,255,0.18); | |
| } | |
| .card:active { transform: translateY(-1px); } | |
| .card-thumb { | |
| aspect-ratio: 4/3; | |
| background: var(--surface2); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .card-thumb .mineral-icon { | |
| font-size: 48px; | |
| user-select: none; | |
| filter: drop-shadow(0 2px 12px rgba(0,0,0,0.6)); | |
| } | |
| .view-label { | |
| position: absolute; | |
| bottom: 10px; | |
| right: 12px; | |
| font-size: 10px; | |
| font-weight: 700; | |
| letter-spacing: 0.1em; | |
| color: var(--accent2); | |
| opacity: 0; | |
| transition: opacity 0.18s ease; | |
| } | |
| .card:hover .view-label { opacity: 1; } | |
| .card-body { | |
| padding: 14px 16px 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| flex: 1; | |
| } | |
| .card-name { | |
| font-size: 16px; | |
| font-weight: 600; | |
| } | |
| .card-meta { | |
| font-size: 12px; | |
| color: var(--text2); | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| } | |
| .badge { | |
| display: inline-block; | |
| padding: 2px 7px; | |
| border-radius: 99px; | |
| font-size: 10px; | |
| font-weight: 600; | |
| letter-spacing: 0.04em; | |
| background: rgba(124,111,255,0.15); | |
| color: var(--accent2); | |
| border: 1px solid rgba(124,111,255,0.25); | |
| margin-top: 6px; | |
| align-self: flex-start; | |
| } | |
| #viewer-page { | |
| display: none; | |
| position: fixed; | |
| inset: 0; | |
| z-index: 10; | |
| flex-direction: column; | |
| background: #000; | |
| } | |
| #viewer-page.active { display: flex; } | |
| .viewer-bar { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| z-index: 20; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 12px 16px; | |
| background: linear-gradient(to bottom, rgba(0,0,0,0.85) 0%, transparent 100%); | |
| pointer-events: none; | |
| } | |
| .viewer-bar > * { pointer-events: auto; } | |
| .back-btn { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| background: rgba(255,255,255,0.1); | |
| border: 1px solid rgba(255,255,255,0.15); | |
| color: #fff; | |
| border-radius: 8px; | |
| padding: 7px 14px; | |
| font-size: 13px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| backdrop-filter: blur(10px); | |
| transition: background 0.15s; | |
| text-decoration: none; | |
| } | |
| .back-btn:hover { background: rgba(255,255,255,0.18); } | |
| .viewer-title { | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: #fff; | |
| text-shadow: 0 1px 4px rgba(0,0,0,0.8); | |
| } | |
| .viewer-sub { | |
| font-size: 11px; | |
| color: rgba(255,255,255,0.6); | |
| margin-left: auto; | |
| } | |
| #canvas-container { | |
| flex: 1; | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #canvas-container canvas { | |
| display: block; | |
| width: 100% ; | |
| height: 100% ; | |
| } | |
| #loading-overlay { | |
| position: absolute; | |
| inset: 0; | |
| background: #000; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 16px; | |
| z-index: 5; | |
| transition: opacity 0.4s ease; | |
| } | |
| #loading-overlay.hidden { | |
| opacity: 0; | |
| pointer-events: none; | |
| } | |
| .spinner { | |
| width: 40px; | |
| height: 40px; | |
| border: 3px solid rgba(124,111,255,0.2); | |
| border-top-color: var(--accent2); | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .loading-name { | |
| font-size: 16px; | |
| font-weight: 600; | |
| color: #fff; | |
| } | |
| .loading-hint { | |
| font-size: 12px; | |
| color: rgba(255,255,255,0.45); | |
| } | |
| #load-progress { | |
| font-size: 12px; | |
| color: var(--accent2); | |
| min-height: 16px; | |
| } | |
| .controls-hint { | |
| position: absolute; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| font-size: 11px; | |
| color: rgba(255,255,255,0.4); | |
| text-align: center; | |
| pointer-events: none; | |
| white-space: nowrap; | |
| } | |
| #viewer-error { | |
| display: none; | |
| position: absolute; | |
| inset: 0; | |
| align-items: center; | |
| justify-content: center; | |
| flex-direction: column; | |
| gap: 12px; | |
| text-align: center; | |
| padding: 24px; | |
| z-index: 6; | |
| } | |
| #viewer-error.visible { display: flex; } | |
| #viewer-error h3 { font-size: 18px; color: #fff; } | |
| #viewer-error p { font-size: 13px; color: rgba(255,255,255,0.5); max-width: 360px; line-height: 1.5; } | |
| @media (max-width: 500px) { | |
| #landing { padding: 28px 12px 48px; } | |
| .brand { margin-bottom: 32px; } | |
| .grid { grid-template-columns: 1fr 1fr; gap: 10px; } | |
| .card-body { padding: 10px 12px 12px; } | |
| .card-name { font-size: 14px; } | |
| .viewer-sub { display: none; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- LANDING --> | |
| <div id="landing"> | |
| <div class="brand"> | |
| <span class="brand-name">Borussia Minerals</span> | |
| <h1>3D Mineral Specimens</h1> | |
| <p>Interactive Gaussian Splat reconstructions. Tap any specimen to explore in 3D.</p> | |
| </div> | |
| <div class="grid" id="specimen-grid"></div> | |
| </div> | |
| <!-- VIEWER --> | |
| <div id="viewer-page"> | |
| <div class="viewer-bar"> | |
| <a class="back-btn" href="#" id="back-btn">← Gallery</a> | |
| <span class="viewer-title" id="viewer-title"></span> | |
| <span class="viewer-sub" id="viewer-sub"></span> | |
| </div> | |
| <div id="canvas-container"> | |
| <div id="loading-overlay"> | |
| <div class="spinner"></div> | |
| <div class="loading-name" id="loading-name"></div> | |
| <div id="load-progress"></div> | |
| <div class="loading-hint">SPZ format loads ~10x faster than PLY</div> | |
| </div> | |
| <div id="viewer-error"> | |
| <h3>Failed to load splat</h3> | |
| <p id="error-detail">The splat file could not be streamed.</p> | |
| </div> | |
| </div> | |
| <div class="controls-hint">Drag to rotate · Scroll / pinch to zoom · Right-drag to pan</div> | |
| </div> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://cdn.jsdelivr.net/npm/three@0.176.0/build/three.module.js" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| // Specimen registry — add entries here for new specimens | |
| // spzPath: SPZ file alongside the PLY (10x smaller, preserves SH3). Viewer prefers SPZ, falls back to PLY. | |
| var SPECIMENS = [ | |
| { | |
| id: "chry-001", | |
| slug: "chry", | |
| name: "Chrysoprase", | |
| location: "Bagdad, AZ", | |
| mineral: "Prehnite (green crystal)", | |
| gaussians: "7k", | |
| icon: "\u{1F7E2}", | |
| plyPath: "runs/v1-macbook-7k/output.ply", | |
| spzPath: "runs/v1-macbook-7k/output.spz", | |
| sizeMB: 133, | |
| }, | |
| { | |
| id: "amet-001", | |
| slug: "amet", | |
| name: "Amethyst", | |
| location: "Unknown", | |
| mineral: "Amethyst", | |
| gaussians: "15k", | |
| icon: "\u{1F49C}", | |
| plyPath: "runs/v1-macbook-15k/output.ply", | |
| spzPath: "runs/v1-macbook-15k/output.spz", | |
| sizeMB: 56, | |
| }, | |
| { | |
| id: "azur-001", | |
| slug: "azur", | |
| name: "Azurite", | |
| location: "Morenci, AZ", | |
| mineral: "Azurite", | |
| gaussians: "15k", | |
| icon: "\u{1F535}", | |
| plyPath: "images_processed/crystal-cropped-15k.ply", | |
| spzPath: "images_processed/crystal-cropped-15k.spz", | |
| sizeMB: 83, | |
| }, | |
| { | |
| id: "cupr-001", | |
| slug: "cupr", | |
| name: "Cuprite on Copper", | |
| location: "Ray Mine, AZ", | |
| mineral: "Cuprite on native copper", | |
| gaussians: "15k", | |
| icon: "\u{1F7E4}", | |
| plyPath: "runs/v1-mama-15k.ply", | |
| spzPath: "runs/v1-mama-15k.spz", | |
| sizeMB: 50, | |
| }, | |
| { | |
| id: "preh-001", | |
| slug: "preh", | |
| name: "Prehnite", | |
| location: "Unknown", | |
| mineral: "Prehnite", | |
| gaussians: "14.7k", | |
| icon: "\u{1F343}", | |
| plyPath: "reruns/v14/FINAL/cleaned.ply", | |
| spzPath: "reruns/v14/FINAL/cleaned.spz", | |
| sizeMB: 3.6, | |
| }, | |
| ]; | |
| var HF_BASE = "https://huggingface.co/datasets/idirectships/splat-"; | |
| function assetUrl(s, subpath) { | |
| return HF_BASE + s.id + "/resolve/main/" + subpath; | |
| } | |
| // Return the preferred URL for a specimen. | |
| // SPZ is always preferred when spzPath is set (all 5 specimens have SPZ as of 2026-05-17). | |
| // HF resolve URLs use CORS restricted to huggingface.co origin so HEAD-probing is not viable | |
| // from *.hf.space — instead we attempt SPZ directly and fall back to PLY on error. | |
| function preferredUrl(s) { | |
| if (s.spzPath) return assetUrl(s, s.spzPath); | |
| return assetUrl(s, s.plyPath); | |
| } | |
| function fallbackUrl(s) { | |
| return assetUrl(s, s.plyPath); | |
| } | |
| // Build specimen cards using DOM methods | |
| var grid = document.getElementById("specimen-grid"); | |
| SPECIMENS.forEach(function(s) { | |
| var card = document.createElement("a"); | |
| card.className = "card"; | |
| card.href = "?s=" + s.slug; | |
| card.setAttribute("data-slug", s.slug); | |
| var thumb = document.createElement("div"); | |
| thumb.className = "card-thumb"; | |
| var iconSpan = document.createElement("span"); | |
| iconSpan.className = "mineral-icon"; | |
| iconSpan.textContent = s.icon; | |
| var viewLabel = document.createElement("span"); | |
| viewLabel.className = "view-label"; | |
| viewLabel.textContent = "VIEW 3D"; | |
| thumb.appendChild(iconSpan); | |
| thumb.appendChild(viewLabel); | |
| var body = document.createElement("div"); | |
| body.className = "card-body"; | |
| var nameEl = document.createElement("div"); | |
| nameEl.className = "card-name"; | |
| nameEl.textContent = s.name; | |
| var meta = document.createElement("div"); | |
| meta.className = "card-meta"; | |
| var locSpan = document.createElement("span"); | |
| locSpan.textContent = "📍 " + s.location; | |
| var gSpan = document.createElement("span"); | |
| gSpan.textContent = "✦ " + s.gaussians + " Gaussians"; | |
| meta.appendChild(locSpan); | |
| meta.appendChild(gSpan); | |
| var badge = document.createElement("span"); | |
| badge.className = "badge"; | |
| badge.textContent = s.spzPath ? "SPZ · ~" + Math.round(s.sizeMB / 10) + "MB" : s.sizeMB + "MB PLY"; | |
| body.appendChild(nameEl); | |
| body.appendChild(meta); | |
| body.appendChild(badge); | |
| card.appendChild(thumb); | |
| card.appendChild(body); | |
| card.addEventListener("click", function(e) { | |
| e.preventDefault(); | |
| openViewer(s.slug); | |
| }); | |
| grid.appendChild(card); | |
| }); | |
| // Viewer state | |
| var viewer = null; | |
| var activeSlug = null; | |
| var GS3D = null; | |
| var landing = document.getElementById("landing"); | |
| var viewerPage = document.getElementById("viewer-page"); | |
| var viewerTitle = document.getElementById("viewer-title"); | |
| var viewerSub = document.getElementById("viewer-sub"); | |
| var loadingOverlay = document.getElementById("loading-overlay"); | |
| var loadingName = document.getElementById("loading-name"); | |
| var loadProgress = document.getElementById("load-progress"); | |
| var viewerError = document.getElementById("viewer-error"); | |
| var errorDetail = document.getElementById("error-detail"); | |
| var canvasContainer = document.getElementById("canvas-container"); | |
| var backBtn = document.getElementById("back-btn"); | |
| async function ensureGS3D() { | |
| if (GS3D) return GS3D; | |
| var mod = await import( | |
| "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.7/build/gaussian-splats-3d.module.js" | |
| ); | |
| GS3D = mod; | |
| return GS3D; | |
| } | |
| async function openViewer(slug) { | |
| if (activeSlug === slug) return; | |
| if (viewer) { | |
| try { viewer.dispose(); } catch(e) {} | |
| viewer = null; | |
| Array.from(canvasContainer.querySelectorAll("canvas")).forEach(function(c) { c.remove(); }); | |
| } | |
| var s = SPECIMENS.find(function(x) { return x.slug === slug; }); | |
| if (!s) return; | |
| activeSlug = slug; | |
| history.pushState({ slug: slug }, "", "?s=" + slug); | |
| landing.style.display = "none"; | |
| viewerPage.classList.add("active"); | |
| loadingOverlay.classList.remove("hidden"); | |
| viewerError.classList.remove("visible"); | |
| loadingName.textContent = s.name; | |
| loadProgress.textContent = ""; | |
| viewerTitle.textContent = s.name; | |
| viewerSub.textContent = s.mineral + " · " + s.location + " · " + s.gaussians + " Gaussians"; | |
| async function tryLoad(url, label) { | |
| loadProgress.textContent = label; | |
| var v = new lib.Viewer({ | |
| rootElement: canvasContainer, | |
| selfDrivenMode: true, | |
| useBuiltInControls: true, | |
| sharedMemoryForWorkers: false, | |
| dynamicScene: false, | |
| webXRMode: 0, | |
| renderMode: 0, | |
| sceneRevealMode: 1, | |
| }); | |
| var lastPct = -1; | |
| v.setMessageHandler(function(msg) { | |
| if (msg && msg.type === "progress") { | |
| var pct = Math.round(msg.percent); | |
| if (pct !== lastPct) { | |
| lastPct = pct; | |
| loadProgress.textContent = label + " " + pct + "%"; | |
| } | |
| } | |
| }); | |
| await v.addSplatScene(url, { | |
| splatAlphaRemovalThreshold: 5, | |
| showLoadingUI: false, | |
| }); | |
| return v; | |
| } | |
| try { | |
| var lib = await ensureGS3D(); | |
| // Try SPZ first (10-20x smaller, SH3 preserved). Fall back to PLY if SPZ fails. | |
| var spzUrl = preferredUrl(s); | |
| var plyUrl = fallbackUrl(s); | |
| try { | |
| viewer = await tryLoad(spzUrl, "Loading SPZ…"); | |
| } catch(spzErr) { | |
| console.warn("SPZ load failed, falling back to PLY:", spzErr); | |
| // Dispose any partial viewer state before retry | |
| try { if (viewer) viewer.dispose(); } catch(e) {} | |
| Array.from(canvasContainer.querySelectorAll("canvas")).forEach(function(c) { c.remove(); }); | |
| viewer = await tryLoad(plyUrl, "Loading PLY…"); | |
| } | |
| loadingOverlay.classList.add("hidden"); | |
| } catch(err) { | |
| console.error("Viewer error:", err); | |
| loadingOverlay.classList.add("hidden"); | |
| viewerError.classList.add("visible"); | |
| errorDetail.textContent = "Could not load " + s.name + ": " + (err && err.message ? err.message : "unknown error"); | |
| } | |
| } | |
| function closeViewer() { | |
| if (viewer) { | |
| try { viewer.dispose(); } catch(e) {} | |
| viewer = null; | |
| Array.from(canvasContainer.querySelectorAll("canvas")).forEach(function(c) { c.remove(); }); | |
| } | |
| activeSlug = null; | |
| viewerPage.classList.remove("active"); | |
| landing.style.display = ""; | |
| history.pushState({}, "", "/"); | |
| } | |
| backBtn.addEventListener("click", function(e) { | |
| e.preventDefault(); | |
| closeViewer(); | |
| }); | |
| function resolveSlug(raw) { | |
| if (!raw) return null; | |
| var clean = raw.toLowerCase().replace(/^splat-/, "").replace(/-001$/, ""); | |
| var found = SPECIMENS.find(function(s) { return s.slug === clean || s.id === raw; }); | |
| return found ? clean : null; | |
| } | |
| var params = new URLSearchParams(location.search); | |
| var initSlug = resolveSlug(params.get("s")); | |
| if (initSlug) { openViewer(initSlug); } | |
| window.addEventListener("popstate", function(e) { | |
| var slug = e.state && e.state.slug; | |
| if (slug) { | |
| openViewer(slug); | |
| } else { | |
| closeViewer(); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |