#!/usr/bin/env node /** * tools/preprocess.mjs — Offline GLB optimization for hmwebviewer. * * SCOPE: GLB/glTF ONLY (gltf-transform pipeline). Other formats shipped as * samples (OBJ/DAE/FBX/IFC) are NOT processed here — they are consumed raw by * their respective three.js loaders; no offline Draco/KTX2 step applies. * * Compresses a glTF/GLB with Draco (geometry) + WebP texture compression in one * pass using gltf-transform. Output is a single self-contained .optimized.glb * ready for the viewer's GLTFLoader (+ DRACOLoader). * * KTX2/Basis texture encoding is NOT done here — it needs the `toktx` platform * encoder (KTX-Software 4.3+). gltf-transform exposes it via the CLI `etc1s` * (lossy, smaller) or `uastc` (higher quality) commands, e.g.: * gltf-transform etc1s * Install KTX-Software from https://github.com/KhronosGroup/KTX-Software first. * Runtime: the viewer's KTX2Loader decodes these at load (verified with the * Khronos ABeautifulGame KTX2+Draco sample — 626ms perceived load). * * USAGE * node tools/preprocess.mjs [output.glb] [--draco-bits N] [--texture FMT] * * --draco-bits N Position quantization bits (default 14). 8..16; lower=smaller/lossier. * --texture FMT Texture re-encode: webp (default) | jpeg | png | none. * * OUTPUT * Default output = input with `.optimized.glb` suffix. Prints before/after * byte sizes + reduction %. * * INSTALL (these are devDependencies now; if absent the script prints the * install command and exits non-zero — no silent skip): * npm install -D @gltf-transform/core @gltf-transform/functions \ * @gltf-transform/extensions @gltf-transform/cli */ async function loadDeps() { let core, fns, ext; try { core = await import('@gltf-transform/core'); fns = await import('@gltf-transform/functions'); ext = await import('@gltf-transform/extensions'); } catch (e) { const msg = (e && e.message) ? e.message : String(e); console.error('[preprocess] Missing required package: ' + msg); console.error('[preprocess] Install the toolchain with:'); console.error( ' npm install -D @gltf-transform/core @gltf-transform/functions ' + '@gltf-transform/extensions @gltf-transform/cli' ); process.exit(1); } return { core, fns, ext }; } const TEXTURE_FORMATS = ['webp', 'jpeg', 'png', 'none']; function parseArgs(argv) { const args = argv.slice(2); if (args.length === 0 || args[0] === '-h' || args[0] === '--help') { return { help: true }; } const positional = []; let dracoBits = 14; let texture = 'webp'; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '--draco-bits') { const v = Number(args[++i]); if (!Number.isInteger(v) || v < 8 || v > 16) { throw new Error('--draco-bits must be an integer 8..16'); } dracoBits = v; } else if (a === '--texture') { const v = String(args[++i]).toLowerCase(); if (!TEXTURE_FORMATS.includes(v)) { throw new Error('--texture must be one of: ' + TEXTURE_FORMATS.join(', ')); } texture = v; } else if (a.startsWith('--')) { throw new Error('Unknown option: ' + a); } else { positional.push(a); } } if (positional.length === 0) { throw new Error('Missing '); } const input = positional[0]; let output = positional[1]; if (!output) { output = input.replace(/\.glb$/i, '') + '.optimized.glb'; } return { input, output, dracoBits, texture }; } function usage() { console.error( 'Usage: node tools/preprocess.mjs [output.glb] [--draco-bits N] [--texture webp|jpeg|png|none]' ); } async function main() { const opts = parseArgs(process.argv); if (opts.help) { usage(); return; } const { core, fns, ext } = await loadDeps(); const { WebIO } = core; const { quantize, dedup, weld, draco, textureCompress, prune } = fns; const { KHRDracoMeshCompression, EXTTextureWebP } = ext; const { promises: fs } = await import('node:fs'); const path = await import('node:path'); const inputPath = path.resolve(opts.input); const outputPath = path.resolve(opts.output); let inputBytes; try { inputBytes = await fs.readFile(inputPath); } catch (e) { console.error('[preprocess] Cannot read input "' + inputPath + '": ' + e.message); process.exit(1); } const beforeSize = inputBytes.byteLength; // Draco encoder must be handed to the extension as a registered dependency. const draco3d = await import('draco3d'); const dracoEncoder = await draco3d.createEncoderModule(); const io = new WebIO() .registerExtensions([KHRDracoMeshCompression, EXTTextureWebP]) .registerDependencies({ 'draco3d.encoder': dracoEncoder }); let doc; try { doc = await io.readBinary(inputBytes); } catch (e) { console.error('[preprocess] Failed to parse glTF: ' + e.message); process.exit(1); } const transforms = []; transforms.push(dedup()); transforms.push(weld({ tolerance: 1e-4 })); transforms.push( quantize({ quantizePosition: opts.dracoBits, quantizeNormal: 10, quantizeTexcoord: 12, quantizeColor: 8, }) ); transforms.push(prune()); transforms.push( draco({ encodeSpeed: 5, decodeSpeed: 5, quantizePosition: opts.dracoBits, quantizeNormal: 10, quantizeTexcoord: 12, quantizeColor: 8, }) ); if (opts.texture !== 'none') { transforms.push(textureCompress({ targetFormat: opts.texture, quality: 8 })); } await doc.transform(...transforms); const outGLB = await io.writeBinary(doc); await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, outGLB); const afterSize = outGLB.byteLength; const reduction = beforeSize > 0 ? ((1 - afterSize / beforeSize) * 100) : 0; const fmt = (n) => (n / 1024).toFixed(1) + ' KiB'; console.log('[preprocess] ' + path.basename(inputPath) + ' -> ' + path.basename(outputPath)); console.log('[preprocess] before: ' + beforeSize + ' bytes (' + fmt(beforeSize) + ')'); console.log('[preprocess] after: ' + afterSize + ' bytes (' + fmt(afterSize) + ')'); console.log( '[preprocess] reduction: ' + reduction.toFixed(1) + '% ' + '(draco-bits=' + opts.dracoBits + ', texture=' + opts.texture + ')' ); } main().catch((e) => { console.error('[preprocess] Fatal: ' + (e && e.stack ? e.stack : e)); process.exit(1); });