splat-explorer / splat-explorer.html
artel3D's picture
redesign: modern dark UI replacing Win95 style — gradient buttons, glassmorphism, responsive
b1f3200
Raw
History Blame Contribute Delete
27.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Splat Explorer — Image to 3D Mesh</title>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}
}
</script>
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.4.0/model-viewer.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0f;
--surface: #13131a;
--surface2: #1a1a24;
--surface3: #22222e;
--border: #2a2a3a;
--border-hover: #3a3a50;
--text: #e8e8f0;
--text-dim: #8888a0;
--text-muted: #555568;
--accent: #6c5ce7;
--accent-light: #a29bfe;
--accent-glow: rgba(108, 92, 231, 0.3);
--green: #00e88a;
--green-dim: rgba(0, 232, 138, 0.15);
--red: #ff6b6b;
--radius: 12px;
--radius-sm: 8px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', monospace;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 14px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── HEADER ── */
header {
background: linear-gradient(135deg, var(--surface) 0%, var(--surface2) 100%);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.header-left { display: flex; align-items: center; gap: 12px; }
.logo-icon {
width: 36px; height: 36px;
background: linear-gradient(135deg, var(--accent), #a29bfe);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
}
header h1 {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--text) 0%, var(--accent-light) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-subtitle {
font-size: 12px;
color: var(--text-dim);
font-weight: 400;
}
.header-badge {
font-size: 11px;
padding: 4px 10px;
background: var(--green-dim);
color: var(--green);
border-radius: 20px;
font-weight: 500;
letter-spacing: 0.02em;
}
/* ── MAIN ── */
.main-panel {
flex: 1;
display: flex;
flex-direction: column;
}
/* ── KEY PANEL ── */
#key-panel {
display: flex;
padding: 12px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
gap: 10px;
align-items: center;
}
.key-label {
font-size: 12px;
color: var(--text-dim);
font-weight: 500;
white-space: nowrap;
}
#key-input {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 8px 14px;
font-size: 13px;
font-family: var(--mono);
transition: border-color 0.2s, box-shadow 0.2s;
}
#key-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
#key-save {
background: var(--surface3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 8px 18px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
font-family: var(--font);
transition: all 0.2s;
}
#key-save:hover {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* ── INPUT ZONE ── */
.input-zone {
display: flex;
gap: 16px;
padding: 20px 24px;
align-items: stretch;
background: var(--bg);
}
#drop-zone {
flex: 1;
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 28px 20px;
text-align: center;
cursor: pointer;
color: var(--text-dim);
position: relative;
background: var(--surface);
transition: all 0.25s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 120px;
}
#drop-zone:hover, #drop-zone.drag-over {
border-color: var(--accent);
background: rgba(108, 92, 231, 0.05);
color: var(--text);
}
.drop-icon {
font-size: 28px;
margin-bottom: 4px;
opacity: 0.6;
}
#drop-zone img#preview {
max-height: 100px; max-width: 100%;
object-fit: contain; display: none;
border-radius: 8px;
}
#drop-zone input[type=file] {
position: absolute; inset: 0; opacity: 0; cursor: pointer;
}
#thumbs-grid {
display: none; flex-wrap: wrap; gap: 4px;
justify-content: center; margin-bottom: 4px;
}
#thumbs-grid img {
width: 52px; height: 52px; object-fit: cover;
border-radius: 6px;
border: 1px solid var(--border);
}
#photo-count { font-size: 12px; color: var(--accent-light); }
.or-divider {
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
text-transform: uppercase;
letter-spacing: 0.1em;
}
#prompt-zone { flex: 1; display: flex; flex-direction: column; gap: 8px; }
.prompt-label {
font-size: 12px;
color: var(--text-dim);
font-weight: 500;
}
#prompt-input {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 14px 16px;
font-size: 13px;
resize: none;
min-height: 80px;
font-family: var(--font);
transition: border-color 0.2s, box-shadow 0.2s;
}
#prompt-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
#prompt-input::placeholder { color: var(--text-muted); }
/* ── GENERATE BUTTON ── */
#generate-btn {
display: block;
margin: 0 24px 20px;
width: calc(100% - 48px);
background: linear-gradient(135deg, var(--accent) 0%, #a29bfe 100%);
border: none;
border-radius: var(--radius);
color: white;
padding: 14px;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
font-family: var(--font);
transition: all 0.25s;
box-shadow: 0 4px 20px var(--accent-glow);
}
#generate-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 28px rgba(108, 92, 231, 0.45);
}
#generate-btn:active { transform: translateY(0); }
#generate-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* ── STATUS BAR ── */
#status-bar {
padding: 10px 24px;
font-size: 12px;
color: var(--text-dim);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: var(--surface);
min-height: 36px;
display: flex;
align-items: center;
gap: 10px;
font-family: var(--mono);
}
#status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
#status-dot.active { background: var(--green); animation: pulse 1.5s ease-in-out infinite; }
#status-dot.error { background: var(--red); animation: none; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
/* ── VIEWERS ── */
.viewers {
display: flex; flex: 1;
min-height: 420px;
background: var(--bg);
}
.viewer-panel {
display: flex; flex-direction: column; flex: 1;
}
.viewer-label {
padding: 8px 20px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--surface);
color: var(--text-dim);
border-bottom: 1px solid var(--border);
}
model-viewer {
flex: 1; width: 100%; min-height: 360px;
background: #080810;
--progress-bar-color: var(--accent);
--progress-bar-height: 3px;
border-radius: 0;
}
/* ── VIEWER TOOLBAR ── */
.viewer-toolbar {
display: flex;
gap: 10px;
padding: 10px 20px;
border-top: 1px solid var(--border);
align-items: center;
flex-wrap: wrap;
background: var(--surface);
}
#wireframe-btn {
background: var(--surface3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-dim);
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
font-family: var(--mono);
font-weight: 500;
transition: all 0.2s;
}
#wireframe-btn:hover {
border-color: var(--green);
color: var(--green);
}
#wireframe-btn.active {
background: var(--green-dim);
border-color: var(--green);
color: var(--green);
}
.bg-control {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-dim);
}
#bg-color {
width: 28px; height: 24px;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0;
background: transparent;
cursor: pointer;
}
#viewer-info {
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
font-family: var(--mono);
}
/* ── DOWNLOADS ── */
.downloads {
gap: 12px;
padding: 14px 24px;
border-top: 1px solid var(--border);
background: var(--surface);
display: none;
align-items: center;
}
.downloads.visible { display: flex; }
#export-name {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 8px 12px;
font-size: 13px;
font-family: var(--mono);
width: 140px;
transition: border-color 0.2s;
}
#export-name:focus {
outline: none;
border-color: var(--accent);
}
.ext-label {
font-size: 13px;
color: var(--text-muted);
font-family: var(--mono);
}
.dl-btn {
background: linear-gradient(135deg, var(--green) 0%, #00c9a7 100%);
border: none;
border-radius: var(--radius-sm);
color: #0a0a0f;
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font);
transition: all 0.2s;
}
.dl-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 232, 138, 0.3);
}
/* ── FOOTER ── */
.footer {
padding: 16px 24px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
border-top: 1px solid var(--border);
background: var(--surface);
}
.footer a {
color: var(--accent-light);
text-decoration: none;
}
.footer a:hover { text-decoration: underline; }
/* ── MOBILE ── */
@media (max-width: 640px) {
header { padding: 12px 16px; }
header h1 { font-size: 16px; }
.input-zone {
flex-direction: column;
padding: 16px;
gap: 12px;
}
.or-divider {
justify-content: center;
padding: 4px 0;
}
#generate-btn {
margin: 0 16px 16px;
width: calc(100% - 32px);
}
#key-panel { padding: 10px 16px; flex-wrap: wrap; }
.key-label { display: none; }
.viewers { min-height: 320px; }
.downloads { padding: 12px 16px; flex-wrap: wrap; }
.viewer-toolbar { padding: 8px 16px; }
.footer { padding: 12px 16px; }
}
</style>
</head>
<body>
<header>
<div class="header-left">
<div class="logo-icon">🐠</div>
<div>
<h1>Splat Explorer</h1>
<div class="header-subtitle">Image & Text to 3D Mesh</div>
</div>
</div>
<span class="header-badge">Free</span>
</header>
<div class="main-panel">
<div id="key-panel">
<span class="key-label">HF Token</span>
<input id="key-input" type="password" placeholder="hf_xxxxxxxxxxxxx" />
<button id="key-save">Save</button>
</div>
<div class="input-zone">
<div id="drop-zone">
<input type="file" id="file-input" accept="image/png,image/jpeg,image/webp" multiple />
<img id="preview" alt="preview" />
<div id="thumbs-grid"></div>
<div class="drop-icon">📸</div>
<span id="drop-label">Drop photos or click to upload</span>
<span id="photo-count"></span>
</div>
<div class="or-divider">or</div>
<div id="prompt-zone">
<span class="prompt-label">Text prompt</span>
<textarea id="prompt-input" placeholder="A ceramic vase with floral patterns..."></textarea>
</div>
</div>
<button id="generate-btn">✦ Generate 3D Mesh</button>
<div id="status-bar">
<div id="status-dot"></div>
<span id="status-text">Ready</span>
</div>
<div class="viewers">
<div class="viewer-panel" style="flex:1;">
<div class="viewer-label">3D Viewer</div>
<div style="position:relative;flex:1;display:flex;flex-direction:column;">
<model-viewer id="glb-viewer" camera-controls auto-rotate
environment-image="neutral" shadow-intensity="0.5"
style="flex:1;width:100%;min-height:360px;background:#080810;">
</model-viewer>
<canvas id="wire-canvas" style="display:none;position:absolute;inset:0;width:100%;height:100%;"></canvas>
</div>
<div class="viewer-toolbar">
<button id="wireframe-btn" onclick="window.toggleWireframe()">◇ Wireframe</button>
<label class="bg-control">
BG
<input id="bg-color" type="color" value="#080810">
</label>
<span id="viewer-info"></span>
</div>
</div>
</div>
<div class="downloads" id="downloads-bar">
<input id="export-name" type="text" value="model" placeholder="filename">
<span class="ext-label">.glb</span>
<a id="dl-ply" class="dl-btn" download="model.glb">↓ Export GLB</a>
</div>
<div class="footer">
Made by <a href="https://huggingface.co/ArtelTaleb" target="_blank">ArtelTaleb</a> · Powered by InstantMesh ·
<a href="https://huggingface.co/spaces/ArtelTaleb/canva3d" target="_blank">Canva3D</a> ·
<a href="https://huggingface.co/spaces/ArtelTaleb/8view-ai-studio" target="_blank">8VIEW AI</a>
</div>
<script type="module">
import * as THREE from 'three'
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
window.toggleWireframe = toggleWireframe
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
function hasWebGL() {
try {
const c = document.createElement('canvas')
return !!(c.getContext('webgl2') || c.getContext('webgl'))
} catch(e) { return false }
}
if (!hasWebGL()) {
document.querySelector('.viewers').innerHTML =
'<div style="display:flex;align-items:center;justify-content:center;flex:1;' +
'color:var(--text-dim);font-family:var(--font);padding:24px;background:var(--surface);">' +
'WebGL not supported on this device.</div>'
document.getElementById('wireframe-btn').style.display = 'none'
}
const keyInput = document.getElementById('key-input')
const keySave = document.getElementById('key-save')
function loadKey() { return localStorage.getItem('hf_token') || '' }
function saveKey(k) { localStorage.setItem('hf_token', k) }
keyInput.value = loadKey()
keySave.addEventListener('click', () => {
saveKey(keyInput.value.trim())
setStatus('Key saved ✓', false)
})
const fileInput = document.getElementById('file-input')
const preview = document.getElementById('preview')
const dropLabel = document.getElementById('drop-label')
const thumbsGrid = document.getElementById('thumbs-grid')
const photoCount = document.getElementById('photo-count')
const dropZone = document.getElementById('drop-zone')
let imageFiles = []
async function handleFiles(fileList) {
const imgs = Array.from(fileList).filter(f => f.type.startsWith('image/'))
if (!imgs.length) return
imageFiles = await Promise.all(imgs.map(f => new Promise(res => {
const r = new FileReader()
r.onload = e => res(e.target.result)
r.readAsDataURL(f)
})))
document.querySelector('.drop-icon').style.display = 'none'
if (imageFiles.length === 1) {
preview.src = imageFiles[0]
preview.style.display = 'block'
thumbsGrid.style.display = 'none'
dropLabel.style.display = 'none'
photoCount.textContent = ''
} else {
preview.style.display = 'none'
thumbsGrid.innerHTML = ''
imageFiles.forEach(src => {
const img = document.createElement('img')
img.src = src
thumbsGrid.appendChild(img)
})
thumbsGrid.style.display = 'flex'
dropLabel.style.display = 'none'
photoCount.textContent = `${imageFiles.length} photos — AI will analyze all`
}
}
fileInput.addEventListener('change', (e) => handleFiles(e.target.files))
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('drag-over') })
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'))
dropZone.addEventListener('drop', (e) => {
e.preventDefault()
dropZone.classList.remove('drag-over')
handleFiles(e.dataTransfer.files)
})
const statusDot = document.getElementById('status-dot')
const statusText = document.getElementById('status-text')
let timerInterval = null
function setStatus(text, active = false, error = false) {
statusText.textContent = text
statusDot.className = active ? 'active' : error ? 'error' : ''
}
function startTimer(label = 'Generating') {
let seconds = 0
timerInterval = setInterval(() => {
seconds++
const mm = String(Math.floor(seconds / 60)).padStart(2, '0')
const ss = String(seconds % 60).padStart(2, '0')
statusText.textContent = `${label}... (${mm}:${ss})`
}, 1000)
}
function stopTimer() {
clearInterval(timerInterval)
timerInterval = null
}
async function generate(input) {
const res = await fetch('/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...input, hf_token: loadKey() })
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || `Server error ${res.status}`)
return data
}
function displayOutputs(output) {
console.log('3D output:', output)
if (!output.glb) {
setStatus('Unexpected output — check console F12', false, true)
return
}
currentGlbUrl = output.glb
document.getElementById('glb-viewer').setAttribute('src', output.glb)
const dlBtn = document.getElementById('dl-ply')
dlBtn.href = output.glb
const fname = (document.getElementById('export-name').value.trim() || 'model').replace(/\.glb$/i,'')
dlBtn.download = fname + '.glb'
document.getElementById('downloads-bar').classList.add('visible')
if (wireframeOn) disposeWireframe()
wireframeOn = true
const wireBtn = document.getElementById('wireframe-btn')
wireBtn.classList.add('active')
document.getElementById('glb-viewer').style.opacity = '0'
const wireCanvas = document.getElementById('wire-canvas')
wireCanvas.style.display = 'block'
initWireframe(wireCanvas, currentGlbUrl)
}
const generateBtn = document.getElementById('generate-btn')
const promptInput = document.getElementById('prompt-input')
generateBtn.addEventListener('click', async () => {
const prompt = promptInput.value.trim()
if (!imageFiles.length && !prompt) {
setStatus('Upload photos or enter a prompt first', false, true)
return
}
generateBtn.disabled = true
document.getElementById('downloads-bar').classList.remove('visible')
let input, timerLabel
if (imageFiles.length > 1) {
input = { images: imageFiles }
timerLabel = 'Analyzing photos + generating 3D'
} else if (imageFiles.length === 1) {
input = { image: imageFiles[0] }
timerLabel = 'Generating 3D'
} else {
input = { prompt }
timerLabel = 'Generating'
}
setStatus('Starting...', true)
startTimer(timerLabel)
try {
const output = await generate(input)
stopTimer()
const note = output.prompt
? ` — "${output.prompt.slice(0, 70)}"`
: output.warning ? ` (${output.warning})` : ''
setStatus('Done ✓' + note, false)
displayOutputs(output)
} catch (err) {
stopTimer()
setStatus(err.message, false, true)
console.error(err)
} finally {
generateBtn.disabled = false
}
})
let wireframeOn = false
let currentGlbUrl = null
let wireRenderer = null, wireScene = null, wireCamera = null, wireControls = null, wireAnimId = null
let wireResizeObserver = null
function disposeWireframe() {
if (wireAnimId) { cancelAnimationFrame(wireAnimId); wireAnimId = null }
if (wireResizeObserver) { wireResizeObserver.disconnect(); wireResizeObserver = null }
if (wireControls) { wireControls.dispose(); wireControls = null }
if (wireScene) {
wireScene.traverse(obj => {
if (obj.geometry) obj.geometry.dispose()
if (obj.material) {
if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose())
else obj.material.dispose()
}
})
wireScene = null
}
if (wireRenderer) { wireRenderer.dispose(); wireRenderer = null }
wireCamera = null
}
function toggleWireframe() {
if (!currentGlbUrl) return
wireframeOn = !wireframeOn
const btn = document.getElementById('wireframe-btn')
const mv = document.getElementById('glb-viewer')
const canvas = document.getElementById('wire-canvas')
if (wireframeOn) {
btn.classList.add('active')
mv.style.opacity = '0'
canvas.style.display = 'block'
initWireframe(canvas, currentGlbUrl)
} else {
btn.classList.remove('active')
mv.style.opacity = '1'
canvas.style.display = 'none'
disposeWireframe()
document.getElementById('viewer-info').textContent = ''
}
}
function initWireframe(canvas, glbUrl) {
disposeWireframe()
const w = canvas.offsetWidth || 600
const h = canvas.offsetHeight || 400
wireRenderer = new THREE.WebGLRenderer({ canvas, antialias: !isMobile })
wireRenderer.setSize(w, h)
wireRenderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1 : 2))
wireRenderer.setClearColor(0x080810)
wireScene = new THREE.Scene()
wireCamera = new THREE.PerspectiveCamera(45, w / h, 0.01, 1000)
wireCamera.position.set(0, 1, 3)
wireControls = new OrbitControls(wireCamera, canvas)
wireControls.enableDamping = true
wireControls.enableZoom = !isMobile
wireScene.add(new THREE.AmbientLight(0xffffff, 0.3))
const dl = new THREE.DirectionalLight(0xffffff, 0.8)
dl.position.set(1, 2, 1)
wireScene.add(dl)
const info = document.getElementById('viewer-info')
info.textContent = 'Loading mesh…'
const loader = new GLTFLoader()
const sceneAtLoad = wireScene
loader.load(glbUrl, gltf => {
if (!wireScene || wireScene !== sceneAtLoad) return
const model = gltf.scene
const box = new THREE.Box3().setFromObject(model)
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
model.position.sub(center)
let triCount = 0
model.traverse(obj => {
if (obj.isMesh) {
obj.material = new THREE.MeshBasicMaterial({ color: 0x6c5ce7, wireframe: true })
if (obj.geometry) {
const idx = obj.geometry.index
triCount += idx ? idx.count / 3 : (obj.geometry.attributes.position.count / 3)
}
}
})
const dist = Math.max(maxDim * 2, 1)
wireCamera.position.set(0, maxDim * 0.4, dist)
wireCamera.near = dist * 0.01
wireCamera.far = dist * 100
wireCamera.updateProjectionMatrix()
wireControls.target.set(0, 0, 0)
wireControls.update()
wireScene.add(model)
info.textContent = `${Math.round(triCount).toLocaleString()} triangles`
}, undefined, () => {
if (!wireScene || wireScene !== sceneAtLoad) return
info.textContent = 'Mesh load error'
})
function animate() {
wireAnimId = requestAnimationFrame(animate)
if (!wireRenderer || !wireScene || !wireCamera) return
wireControls.update()
wireRenderer.render(wireScene, wireCamera)
}
animate()
wireResizeObserver = new ResizeObserver(() => {
if (!wireRenderer || !wireCamera) return
const nw = canvas.offsetWidth || 600
const nh = canvas.offsetHeight || 400
wireRenderer.setSize(nw, nh)
wireCamera.aspect = nw/nh
wireCamera.updateProjectionMatrix()
})
wireResizeObserver.observe(canvas)
}
document.getElementById('export-name').addEventListener('input', (e) => {
const fname = (e.target.value.trim() || 'model').replace(/\.glb$/i,'')
document.getElementById('dl-ply').download = fname + '.glb'
})
document.getElementById('bg-color').addEventListener('input', (e) => {
document.getElementById('glb-viewer').style.backgroundColor = e.target.value
if (wireRenderer) wireRenderer.setClearColor(e.target.value)
})
</script>
</div>
</body>
</html>