import { CompareViewer } from './viewer.js'; import { analyzeBytes, optimize, PRESETS } from './optimize.js'; const $ = (id) => document.getElementById(id); const state = { name: null, origBytes: null, origReport: null, optBytes: null, optReport: null, viewer: null, busy: false, }; // ---- error slider is log-scale: 0.01% .. 10% of mesh radius ---- const sliderToError = (s) => Math.pow(10, -4 + 3 * (s / 100)); const errorToSlider = (e) => Math.round(((Math.log10(e) + 4) / 3) * 100); function fmtBytes(n) { if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; if (n >= 1024) return (n / 1024).toFixed(0) + ' KB'; return n + ' B'; } const fmtInt = (n) => n.toLocaleString('en-US'); function updateLabels() { $('ratio-val').textContent = $('ratio').value + '%'; const e = sliderToError(+$('error').value); $('error-val').textContent = (e * 100).toPrecision(2) + '%'; $('quality-val').textContent = $('quality').value; } function applyPreset(name) { const p = PRESETS[name]; $('ratio').value = Math.round(p.ratio * 100); $('error').value = errorToSlider(p.error); $('max-tex').value = String(p.maxTex <= 4096 ? p.maxTex : 99999); $('webp').checked = p.webp; document.querySelectorAll('.preset').forEach((b) => b.classList.toggle('active', b.dataset.preset === name) ); updateLabels(); } function currentOpts() { return { ratio: +$('ratio').value / 100, error: sliderToError(+$('error').value), maxTex: +$('max-tex').value, webp: $('webp').checked, texQuality: +$('quality').value / 100, join: $('join').checked, quantize: $('quantize').checked, meshopt: $('meshopt').checked, }; } function statsTable(report, base) { const rows = [ ['file', fmtBytes(report.bytes), base && base.bytes], ['triangles', fmtInt(report.tris), base && base.tris], ['vertices', fmtInt(report.verts), base && base.verts], ['draw calls', fmtInt(report.drawCalls), base && base.drawCalls], ['textures', `${report.textures.length} (${fmtBytes(report.texBytes)})`, base && base.texBytes], ]; const rawVals = [report.bytes, report.tris, report.verts, report.drawCalls, report.texBytes]; let html = ''; rows.forEach(([label, val, baseVal], i) => { let delta = ''; if (baseVal != null && baseVal > 0) { const pct = ((rawVals[i] - baseVal) / baseVal) * 100; const cls = pct <= 0 ? 'delta-good' : 'delta-bad'; delta = ``; } html += `${delta}`; }); html += '
${pct > 0 ? '+' : ''}${pct.toFixed(0)}%
${label}${val}
'; const extras = []; if (report.animations.length) extras.push(`${report.animations.length} clip(s)`); if (report.skins) extras.push(`${report.skins} skin(s)`); if (report.morphPrims) extras.push(`${report.morphPrims} morph prim(s)`); if (extras.length) html += `
${extras.join(' · ')}
`; return html; } function setStatus(text) { $('status').textContent = text; } async function loadFile(file) { const bytes = await file.arrayBuffer(); setStatus('reading…'); let report; try { report = await analyzeBytes(bytes); } catch (e) { if (/draco/i.test(String(e))) { alert('This GLB is Draco-compressed — not supported yet. Re-export without Draco (it will still come out smaller here).'); } else { alert('Could not read this file as a GLB:\n' + (e.message || e)); } setStatus(''); return; } state.name = file.name; state.origBytes = bytes; state.origReport = report; state.optBytes = null; state.optReport = null; $('dropzone').classList.add('hidden'); $('viewer-ui').classList.remove('hidden'); $('file-info').classList.remove('hidden'); $('file-name').textContent = file.name; $('file-meta').textContent = `${fmtBytes(report.bytes)} · ${fmtInt(report.tris)} tris · ${report.meshes} mesh(es) · ${report.materials} material(s)`; const rigged = report.skins > 0 || report.animations.length > 0; $('rig-badge').classList.toggle('hidden', !rigged); $('join').disabled = rigged; if (rigged) $('join').checked = false; if (!state.viewer) { state.viewer = new CompareViewer($('pane-left'), $('pane-right'), $('viewers')); } state.viewer.clear('right'); $('right-empty').style.display = 'flex'; await state.viewer.load('left', bytes.slice(0)); // animation UI const names = state.viewer.clipNames(); $('anim-row').hidden = names.length === 0; if (names.length) { $('anim-select').innerHTML = names .map((n, i) => ``) .join(''); state.viewer.playClip(0); } $('stats-left').innerHTML = statsTable(report, null); $('stats-right').innerHTML = ''; $('notes').classList.add('hidden'); $('optimize').disabled = false; $('export').disabled = true; setStatus(''); } async function runOptimize() { if (state.busy || !state.origBytes) return; state.busy = true; $('optimize').disabled = true; $('optimize').textContent = 'Crunching…'; try { const t0 = performance.now(); const { bytes, report, notes } = await optimize(state.origBytes, currentOpts(), (s) => setStatus(s + '…') ); const secs = ((performance.now() - t0) / 1000).toFixed(1); state.optBytes = bytes; state.optReport = report; $('right-empty').style.display = 'none'; await state.viewer.load('right', bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); const sel = +($('anim-select').value || 0); if (state.viewer.clipNames().length) state.viewer.playClip(sel); $('stats-right').innerHTML = statsTable(report, state.origReport); const saved = 1 - report.bytes / state.origReport.bytes; setStatus(`done in ${secs}s — ${(saved * 100).toFixed(0)}% smaller`); const notesEl = $('notes'); notesEl.classList.toggle('hidden', notes.length === 0); notesEl.innerHTML = notes.map((n) => `
· ${n}
`).join(''); $('export').disabled = false; } catch (e) { console.error(e); alert('Optimize failed:\n' + (e.message || e)); setStatus('failed'); } finally { state.busy = false; $('optimize').disabled = false; $('optimize').textContent = 'Optimize'; } } function exportGlb() { if (!state.optBytes) return; const base = state.name.replace(/\.glb$/i, ''); const blob = new Blob([state.optBytes], { type: 'model/gltf-binary' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${base}.opt.glb`; a.click(); URL.revokeObjectURL(a.href); } // ---- wiring ---- document.querySelectorAll('.preset').forEach((b) => b.addEventListener('click', () => applyPreset(b.dataset.preset)) ); ['ratio', 'error', 'quality'].forEach((id) => $(id).addEventListener('input', updateLabels)); $('optimize').addEventListener('click', runOptimize); $('export').addEventListener('click', exportGlb); $('browse').addEventListener('click', () => $('file-input').click()); $('file-input').addEventListener('change', (e) => { if (e.target.files[0]) loadFile(e.target.files[0]); }); $('wireframe').addEventListener('change', (e) => state.viewer?.setWireframe(e.target.checked)); $('anim-select').addEventListener('change', (e) => state.viewer?.playClip(+e.target.value)); $('anim-play').addEventListener('change', (e) => state.viewer?.setPlaying(e.target.checked)); window.addEventListener('dragover', (e) => { e.preventDefault(); $('dropzone').classList.add('drag'); }); window.addEventListener('dragleave', () => $('dropzone').classList.remove('drag')); window.addEventListener('drop', (e) => { e.preventDefault(); $('dropzone').classList.remove('drag'); const file = [...(e.dataTransfer?.files || [])].find((f) => /\.glb$/i.test(f.name)); if (file) loadFile(file); }); applyPreset('balanced'); // ---- dev test hook: ?test=file.glb&run=1 loads from /public and optimizes ---- const params = new URLSearchParams(location.search); const testFile = params.get('test'); if (testFile) { (async () => { const res = await fetch('/' + testFile); const buf = await res.arrayBuffer(); await loadFile(new File([buf], testFile)); if (params.get('run')) await runOptimize(); window.__testDone = true; })(); }