Spaces:
Running
Running
| <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> | |