SHRINKGOD v1: local GLB optimizer — synced compare viewer, rig-protected simplify, texture crunch

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-30 22:19:14 +10:00
commit ab8f5ee383
10 changed files with 2707 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
public/test-*.glb

44
README.md Normal file
View File

@ -0,0 +1,44 @@
# 🗜️ SHRINKGOD — local GLB optimizer
Drop in a heavy GLB, pick a target, compare original vs optimized side-by-side (synced camera, animation playback, wireframe), export. **Everything runs client-side in the browser** — gltf-transform + meshoptimizer WASM. No uploads, files never leave the machine.
## Run
```bash
npm install
npm run dev # http://localhost:5173
npm run build # static build in dist/ — host anywhere, still fully local
```
Dev test hook: `?test=<file-in-public>.glb&run=1` auto-loads and optimizes.
## Pipeline
dedup → flatten+join (static models only) → weld → per-primitive meshopt simplify → animation resample → prune → texture resize/re-encode → quantize → (optional) meshopt compression.
### Rig protection (automatic)
- Skinned meshes never go below **50% kept geometry** and error is capped at 0.5% — vertex collapse near joints wrecks skin weights in motion.
- Morph-target (blendshape) primitives are left untouched.
- Mesh joining is disabled for rigged/animated models (would break node animation).
- Best results: **optimize before rigging** — static geometry can be crunched far harder.
### Textures
- Longest side clamped to the chosen max (canvas re-encode).
- Color/emissive maps optionally converted to WebP (quality slider); normal/roughness/occlusion maps always stay lossless PNG.
- Re-encode is skipped if it wouldn't shrink the file.
### Presets
| | geometry kept | error | max texture | webp |
|---|---|---|---|---|
| Light | 50% | 0.5% | 2048 | no |
| Balanced | 25% | 1% | 1024 | yes |
| Crunch | 10% | 5% | 512 | yes |
## Known limits
- Draco-compressed input not supported yet (friendly error; re-export without Draco).
- Meshopt compression output (`EXT_meshopt_compression`) reads fine in three.js but **not in Blender** — leave it off for assets going back into a DCC.
- KTX2/basis textures pass through untouched.
## Bench (M5 MacBook Pro, Balanced preset)
- 15.8 MB photogrammetry room scan (151k tris, 13 MB textures) → **1.5 MB** (90%) in 5 s.
- 3.5 MB rigged character (18.6k tris, 20 clips) → **2.7 MB** (24%), clips + skinning intact (rig clamp limits geometry reduction by design).

106
index.html Normal file
View File

@ -0,0 +1,106 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SHRINKGOD — local GLB optimizer</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🗜️</text></svg>" />
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<header>
<div class="brand">🗜️ <b>SHRINKGOD</b><span class="sub">GLB optimizer</span></div>
<div class="local-badge" title="Everything runs in this browser tab. No uploads.">🔒 100% local — files never leave this machine</div>
</header>
<div id="layout">
<aside id="sidebar">
<section id="file-info" class="panel hidden">
<div id="file-name" class="mono"></div>
<div id="file-meta" class="dim"></div>
<div id="rig-badge" class="badge hidden">🦴 rig detected — bones &amp; skin weights protected</div>
</section>
<section class="panel">
<h3>Target</h3>
<div class="preset-row">
<button class="preset" data-preset="light">Light</button>
<button class="preset active" data-preset="balanced">Balanced</button>
<button class="preset" data-preset="crunch">Crunch</button>
</div>
<label class="slider-label">Geometry kept <span id="ratio-val" class="mono accent">25%</span>
<input id="ratio" type="range" min="2" max="100" value="25" />
</label>
<label class="slider-label">Error tolerance <span id="error-val" class="mono accent">1.0%</span>
<input id="error" type="range" min="1" max="100" value="32" />
</label>
</section>
<section class="panel">
<h3>Textures</h3>
<label class="row">Max size
<select id="max-tex">
<option value="99999">Original</option>
<option value="2048">2048</option>
<option value="1024" selected>1024</option>
<option value="512">512</option>
<option value="256">256</option>
</select>
</label>
<label class="row check"><input id="webp" type="checkbox" checked /> Convert color maps to WebP</label>
<label class="slider-label">Quality <span id="quality-val" class="mono accent">85</span>
<input id="quality" type="range" min="40" max="100" value="85" />
</label>
<div class="dim tiny">Normal / roughness maps stay lossless PNG.</div>
</section>
<section class="panel">
<h3>Structure</h3>
<label class="row check"><input id="join" type="checkbox" checked /> Join meshes <span class="dim tiny">(static only)</span></label>
<label class="row check"><input id="quantize" type="checkbox" checked /> Quantize vertex data</label>
<label class="row check"><input id="meshopt" type="checkbox" /> Meshopt compression <span class="dim tiny">(web loaders only — Blender can't read it)</span></label>
</section>
<button id="optimize" class="big-btn" disabled>Optimize</button>
<button id="export" class="big-btn secondary" disabled>Export GLB</button>
<div id="notes" class="panel hidden"></div>
</aside>
<main id="main">
<div id="dropzone">
<div class="dz-inner">
<div class="dz-icon">⬇︎</div>
<div>Drop a <b>.glb</b> here<br /><span class="dim">or</span></div>
<button id="browse">Choose file</button>
<input id="file-input" type="file" accept=".glb,model/gltf-binary" hidden />
</div>
</div>
<div id="viewer-ui" class="hidden">
<div id="toolbar">
<label class="row check"><input id="wireframe" type="checkbox" /> Wireframe</label>
<label class="row" id="anim-row" hidden>Clip
<select id="anim-select"></select>
<label class="row check"><input id="anim-play" type="checkbox" checked /> Play</label>
</label>
<div id="status" class="dim"></div>
</div>
<div id="viewers">
<div class="pane-wrap">
<div class="pane-label">ORIGINAL</div>
<div id="pane-left" class="pane"></div>
<div id="stats-left" class="stats"></div>
</div>
<div class="pane-wrap">
<div class="pane-label">OPTIMIZED</div>
<div id="pane-right" class="pane"><div id="right-empty" class="pane-empty">hit <b>Optimize</b></div></div>
<div id="stats-right" class="stats"></div>
</div>
</div>
</div>
</main>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1601
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "shrinkgod",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@gltf-transform/core": "^4.4.2",
"@gltf-transform/extensions": "^4.4.2",
"@gltf-transform/functions": "^4.4.2",
"meshoptimizer": "^1.2.0",
"three": "^0.185.1"
},
"devDependencies": {
"vite": "^8.2.2"
}
}

239
src/main.js Normal file
View File

@ -0,0 +1,239 @@
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 = '<table>';
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 = `<td class="num ${cls}">${pct > 0 ? '+' : ''}${pct.toFixed(0)}%</td>`;
}
html += `<tr><td class="dim">${label}</td><td class="num">${val}</td>${delta}</tr>`;
});
html += '</table>';
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 += `<div class="dim tiny">${extras.join(' · ')}</div>`;
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) => `<option value="${i}">${n}</option>`)
.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) => `<div>· ${n}</div>`).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;
})();
}

274
src/optimize.js Normal file
View File

@ -0,0 +1,274 @@
import { WebIO } from '@gltf-transform/core';
import {
ALL_EXTENSIONS,
EXTTextureWebP,
EXTMeshoptCompression,
} from '@gltf-transform/extensions';
import {
dedup,
prune,
weld,
weldPrimitive,
simplifyPrimitive,
resample,
flatten,
join,
quantize,
listTextureSlots,
} from '@gltf-transform/functions';
import { MeshoptDecoder, MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer';
const TRIANGLES = 4;
let _io = null;
async function getIO() {
if (!_io) {
await Promise.all([MeshoptDecoder.ready, MeshoptEncoder.ready]);
_io = new WebIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({
'meshopt.decoder': MeshoptDecoder,
'meshopt.encoder': MeshoptEncoder,
});
}
return _io;
}
export const PRESETS = {
light: { ratio: 0.5, error: 0.005, maxTex: 2048, webp: false },
balanced: { ratio: 0.25, error: 0.01, maxTex: 1024, webp: true },
crunch: { ratio: 0.1, error: 0.05, maxTex: 512, webp: true },
};
// Skinned meshes never go below this keep-ratio, and error is capped —
// collapsing verts near joints wrecks skin weights and silhouettes in motion.
const SKINNED_MIN_RATIO = 0.5;
const SKINNED_MAX_ERROR = 0.005;
function skinnedMeshSet(root) {
const set = new Set();
for (const node of root.listNodes()) {
if (node.getSkin() && node.getMesh()) set.add(node.getMesh());
}
return set;
}
export function reportDoc(doc, byteLength) {
const root = doc.getRoot();
let tris = 0;
let verts = 0;
let prims = 0;
let morphPrims = 0;
for (const mesh of root.listMeshes()) {
for (const prim of mesh.listPrimitives()) {
prims++;
if (prim.listTargets().length > 0) morphPrims++;
const pos = prim.getAttribute('POSITION');
if (!pos) continue;
verts += pos.getCount();
const idx = prim.getIndices();
const count = idx ? idx.getCount() : pos.getCount();
if (prim.getMode() === TRIANGLES) tris += Math.floor(count / 3);
}
}
let drawCalls = 0;
for (const node of root.listNodes()) {
const mesh = node.getMesh();
if (mesh) drawCalls += mesh.listPrimitives().length;
}
const textures = root.listTextures().map((tex) => {
let size = null;
try { size = tex.getSize(); } catch { /* unknown mime */ }
return {
name: tex.getName() || '',
mime: tex.getMimeType(),
bytes: tex.getImage() ? tex.getImage().byteLength : 0,
size,
};
});
return {
bytes: byteLength,
tris,
verts,
prims,
morphPrims,
drawCalls,
meshes: root.listMeshes().length,
materials: root.listMaterials().length,
textures,
texBytes: textures.reduce((s, t) => s + t.bytes, 0),
animations: root.listAnimations().map((a) => a.getName() || 'clip'),
skins: root.listSkins().length,
};
}
export async function analyzeBytes(bytes) {
const io = await getIO();
const doc = await io.readBinary(new Uint8Array(bytes));
return reportDoc(doc, bytes.byteLength);
}
function isColorSlot(slot) {
return /base|diffuse|emissive|sheenColor|specularColor/i.test(slot);
}
async function processTextures(doc, opts, notes) {
const textures = doc.getRoot().listTextures();
let touched = 0;
for (const tex of textures) {
const image = tex.getImage();
if (!image) continue;
const mime = tex.getMimeType();
if (!/^image\/(png|jpeg|webp)$/.test(mime)) continue; // ktx2 etc: leave alone
let bitmap;
try {
bitmap = await createImageBitmap(new Blob([image], { type: mime }), {
colorSpaceConversion: 'none',
premultiplyAlpha: 'none',
});
} catch {
notes.push(`could not decode texture "${tex.getName() || mime}" — left as-is`);
continue;
}
const longest = Math.max(bitmap.width, bitmap.height);
const scale = Math.min(1, opts.maxTex / longest);
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
const needsResize = scale < 1;
// Data textures (normal/ORM/etc) stay lossless; color can go webp/jpeg.
const slots = listTextureSlots(tex);
const color = slots.length === 0 || slots.some(isColorSlot);
let targetMime;
if (!color) targetMime = 'image/png';
else if (opts.webp) targetMime = 'image/webp';
else targetMime = mime === 'image/webp' ? 'image/webp' : mime; // never png->jpeg (alpha)
if (!needsResize && targetMime === mime && mime === 'image/png') {
bitmap.close();
continue; // nothing to gain re-encoding png->png at same size
}
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
const lossy = targetMime !== 'image/png';
const blob = await canvas.convertToBlob({
type: targetMime,
quality: lossy ? opts.texQuality : undefined,
});
const out = new Uint8Array(await blob.arrayBuffer());
// Keep the original if we somehow made it bigger without shrinking dims.
if (!needsResize && out.byteLength >= image.byteLength) continue;
tex.setImage(out).setMimeType(blob.type);
touched++;
}
if (doc.getRoot().listTextures().some((t) => t.getMimeType() === 'image/webp')) {
doc.createExtension(EXTTextureWebP).setRequired(true);
}
return touched;
}
/**
* bytes: ArrayBuffer of a .glb
* opts: { ratio, error, maxTex, webp, texQuality, join, quantize, meshopt }
*/
export async function optimize(bytes, opts, onStatus = () => {}) {
const io = await getIO();
await MeshoptSimplifier.ready;
const notes = [];
onStatus('parsing');
const doc = await io.readBinary(new Uint8Array(bytes.slice(0)));
const root = doc.getRoot();
const rigged = root.listSkins().length > 0 || root.listAnimations().length > 0;
onStatus('deduplicating');
await doc.transform(dedup());
if (opts.join && !rigged) {
onStatus('joining meshes');
try {
await doc.transform(flatten(), join());
} catch (e) {
notes.push(`join skipped: ${e.message}`);
}
}
onStatus('welding');
await doc.transform(weld());
onStatus('simplifying geometry');
const skinned = skinnedMeshSet(root);
let skippedMorph = 0;
let clampedSkinned = 0;
for (const mesh of root.listMeshes()) {
const isSkinned = skinned.has(mesh);
for (const prim of mesh.listPrimitives()) {
if (prim.getMode() !== TRIANGLES) continue;
if (prim.listTargets().length > 0) {
skippedMorph++;
continue;
}
let { ratio, error } = opts;
let lockBorder = false;
if (isSkinned) {
if (ratio < SKINNED_MIN_RATIO) {
ratio = SKINNED_MIN_RATIO;
clampedSkinned++;
}
error = Math.min(error, SKINNED_MAX_ERROR);
lockBorder = true;
}
try {
weldPrimitive(prim);
simplifyPrimitive(prim, { simplifier: MeshoptSimplifier, ratio, error, lockBorder });
} catch (e) {
notes.push(`simplify skipped on "${mesh.getName() || 'mesh'}": ${e.message}`);
}
}
}
if (root.listAnimations().length > 0) {
onStatus('resampling animations');
try {
await doc.transform(resample());
} catch (e) {
notes.push(`animation resample skipped: ${e.message}`);
}
}
onStatus('pruning');
await doc.transform(prune());
onStatus('processing textures');
await processTextures(doc, opts, notes);
if (opts.quantize || opts.meshopt) {
onStatus('quantizing');
try {
await doc.transform(quantize());
} catch (e) {
notes.push(`quantize skipped: ${e.message}`);
}
}
if (opts.meshopt) {
doc
.createExtension(EXTMeshoptCompression)
.setRequired(true)
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.FILTER });
}
if (skippedMorph) notes.push(`${skippedMorph} morph-target primitive(s) left untouched (blendshape protection)`);
if (clampedSkinned) notes.push(`${clampedSkinned} skinned primitive(s) clamped to keep ≥${SKINNED_MIN_RATIO * 100}% (rig protection)`);
onStatus('writing glb');
const out = await io.writeBinary(doc);
const report = reportDoc(doc, out.byteLength);
return { bytes: out, report, notes, rigged };
}

221
src/style.css Normal file
View File

@ -0,0 +1,221 @@
* { box-sizing: border-box; margin: 0; }
:root {
--bg: #0c0e11;
--panel: #14171c;
--panel-2: #1a1e24;
--border: #262b33;
--text: #d7dce3;
--dim: #8a93a0;
--accent: #7ee787;
--accent-dim: #2e5138;
--bad: #ff7b72;
}
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font: 14px/1.45 -apple-system, system-ui, sans-serif;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.dim { color: var(--dim); }
.tiny { font-size: 11px; }
.accent { color: var(--accent); }
.hidden, [hidden] { display: none !important; }
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.brand { font-size: 16px; letter-spacing: 0.5px; }
.brand .sub { color: var(--dim); font-size: 12px; margin-left: 10px; }
.local-badge {
font-size: 12px;
color: var(--accent);
background: var(--accent-dim);
border-radius: 20px;
padding: 4px 12px;
}
#layout { display: flex; flex: 1 1 auto; min-height: 0; }
#sidebar {
width: 270px;
flex: 0 0 auto;
overflow-y: auto;
padding: 12px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 12px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
}
.panel h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--dim);
margin-bottom: 10px;
}
#file-name { font-size: 13px; word-break: break-all; }
#file-meta { font-size: 12px; margin-top: 4px; }
.badge {
margin-top: 8px;
font-size: 11px;
background: #2a2416;
color: #e3b341;
border-radius: 6px;
padding: 5px 8px;
}
.preset-row { display: flex; gap: 6px; margin-bottom: 12px; }
.preset {
flex: 1;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 0;
cursor: pointer;
font-size: 12px;
}
.preset.active { border-color: var(--accent); color: var(--accent); }
.slider-label { display: block; font-size: 12px; color: var(--dim); margin-top: 8px; }
.slider-label span { float: right; }
input[type="range"] { width: 100%; accent-color: var(--accent); margin-top: 4px; }
.row { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-top: 6px; }
.row.check { cursor: pointer; }
.row select {
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 6px;
margin-left: auto;
}
input[type="checkbox"] { accent-color: var(--accent); }
.big-btn {
padding: 12px;
font-size: 15px;
font-weight: 600;
border-radius: 8px;
border: none;
cursor: pointer;
background: var(--accent);
color: #08110a;
}
.big-btn.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
.big-btn:disabled { opacity: 0.35; cursor: default; }
#notes { font-size: 12px; color: var(--dim); }
#notes div { margin-top: 4px; }
#main { flex: 1 1 auto; position: relative; display: flex; min-width: 0; }
#dropzone {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
#dropzone.drag { background: rgba(126, 231, 135, 0.06); }
.dz-inner {
text-align: center;
border: 2px dashed var(--border);
border-radius: 16px;
padding: 60px 80px;
font-size: 18px;
}
.dz-icon { font-size: 42px; margin-bottom: 12px; }
#browse {
margin-top: 14px;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 20px;
font-size: 14px;
cursor: pointer;
}
#viewer-ui { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#toolbar {
display: flex;
align-items: center;
gap: 20px;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
#toolbar .row { margin-top: 0; }
#status { margin-left: auto; font-size: 12px; }
#viewers { flex: 1; display: flex; min-height: 0; }
.pane-wrap {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
}
.pane-wrap + .pane-wrap { border-left: 1px solid var(--border); }
.pane-label {
position: absolute;
top: 8px;
left: 10px;
z-index: 2;
font-size: 10px;
letter-spacing: 2px;
color: var(--dim);
background: rgba(12, 14, 17, 0.7);
padding: 2px 8px;
border-radius: 4px;
}
.pane { flex: 1; min-height: 0; position: relative; }
.pane canvas { display: block; width: 100%; height: 100%; }
.pane-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--dim);
gap: 4px;
z-index: 1;
}
.stats {
flex: 0 0 auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
padding: 8px 12px;
border-top: 1px solid var(--border);
background: var(--panel);
min-height: 76px;
}
.stats table { width: 100%; border-collapse: collapse; }
.stats td { padding: 1px 6px 1px 0; }
.stats td.num { text-align: right; }
.delta-good { color: var(--accent); }
.delta-bad { color: var(--bad); }

189
src/viewer.js Normal file
View File

@ -0,0 +1,189 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { MeshoptDecoder } from 'meshoptimizer';
const BG = new THREE.Color(0x0c0e11);
function disposeObject(obj) {
obj.traverse((child) => {
if (child.geometry) child.geometry.dispose();
const mats = Array.isArray(child.material) ? child.material : child.material ? [child.material] : [];
for (const m of mats) {
for (const key of Object.keys(m)) {
if (m[key] && m[key].isTexture) m[key].dispose();
}
m.dispose();
}
});
}
class Pane {
constructor(el) {
this.el = el;
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
el.appendChild(this.renderer.domElement);
this.scene = new THREE.Scene();
this.scene.background = BG;
const pmrem = new THREE.PMREMGenerator(this.renderer);
this.scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
pmrem.dispose();
this.grid = new THREE.GridHelper(4, 20, 0x2c3138, 0x1b1f24);
this.scene.add(this.grid);
this.model = null;
this.mixer = null;
this.clips = [];
}
setModel(gltf) {
if (this.model) {
this.scene.remove(this.model);
disposeObject(this.model);
}
this.model = gltf.scene;
this.clips = gltf.animations || [];
this.mixer = this.clips.length ? new THREE.AnimationMixer(this.model) : null;
this.scene.add(this.model);
}
resize() {
const w = this.el.clientWidth;
const h = this.el.clientHeight;
if (w && h) this.renderer.setSize(w, h, false);
return w / Math.max(1, h);
}
}
export class CompareViewer {
constructor(leftEl, rightEl, dragEl) {
this.left = new Pane(leftEl);
this.right = new Pane(rightEl);
this.panes = [this.left, this.right];
this.camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000);
this.camera.position.set(2, 1.5, 3);
// One controls instance on the shared container: both panes stay in sync.
this.controls = new OrbitControls(this.camera, dragEl);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.12;
this.loader = new GLTFLoader();
this.loader.setMeshoptDecoder(MeshoptDecoder);
this.clock = new THREE.Clock();
this.playing = true;
this.wireframe = false;
this.framed = false;
const onResize = () => {
const aspect = this.left.resize();
this.right.resize();
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
};
new ResizeObserver(onResize).observe(leftEl);
onResize();
this.renderer = this.renderLoop.bind(this);
requestAnimationFrame(this.renderer);
}
parse(arrayBuffer) {
return new Promise((resolve, reject) => {
this.loader.parse(arrayBuffer, '', resolve, reject);
});
}
async load(side, arrayBuffer) {
const gltf = await this.parse(arrayBuffer);
const pane = side === 'left' ? this.left : this.right;
pane.setModel(gltf);
this.applyWireframe(pane);
if (side === 'left') {
this.frame(gltf.scene);
}
return gltf;
}
clear(side) {
const pane = side === 'left' ? this.left : this.right;
if (pane.model) {
pane.scene.remove(pane.model);
disposeObject(pane.model);
pane.model = null;
pane.mixer = null;
pane.clips = [];
}
}
frame(object) {
const box = new THREE.Box3().setFromObject(object);
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const dist = maxDim * 1.6;
this.camera.position.set(center.x + dist * 0.7, center.y + dist * 0.45, center.z + dist * 0.85);
this.camera.near = maxDim / 100;
this.camera.far = maxDim * 100;
this.camera.updateProjectionMatrix();
this.controls.target.copy(center);
this.controls.update();
for (const pane of this.panes) {
pane.grid.position.y = box.min.y;
pane.grid.scale.setScalar(Math.max(1, maxDim / 2));
}
this.framed = true;
}
clipNames() {
return this.left.clips.map((c) => c.name || 'clip');
}
playClip(index) {
for (const pane of this.panes) {
if (!pane.mixer) continue;
pane.mixer.stopAllAction();
if (index < 0) continue;
// Match by name first (optimized file may reorder), fall back to index.
const want = this.left.clips[index];
const clip =
(want && pane.clips.find((c) => c.name === want.name)) || pane.clips[index];
if (clip) pane.mixer.clipAction(clip).reset().play();
}
}
setPlaying(v) {
this.playing = v;
}
applyWireframe(pane) {
if (!pane.model) return;
pane.model.traverse((child) => {
const mats = Array.isArray(child.material) ? child.material : child.material ? [child.material] : [];
for (const m of mats) if ('wireframe' in m) m.wireframe = this.wireframe;
});
}
setWireframe(v) {
this.wireframe = v;
this.panes.forEach((p) => this.applyWireframe(p));
}
renderLoop() {
const delta = this.clock.getDelta();
this.controls.update();
for (const pane of this.panes) {
if (pane.mixer && this.playing) pane.mixer.update(delta);
pane.renderer.render(pane.scene, this.camera);
}
requestAnimationFrame(this.renderer);
}
}

5
vite.config.js Normal file
View File

@ -0,0 +1,5 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: './',
server: { port: 5173, strictPort: true },
});