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 = `${pct > 0 ? '+' : ''}${pct.toFixed(0)}% `;
}
html += `${label} ${val} ${delta} `;
});
html += '
';
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) => `${n} `)
.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;
})();
}