Files
lectom bedb357259 chore(monorepo): import hmwebviewer history at a717900
git-subtree-dir: apps/viewer-3d
git-subtree-mainline: 5e10bb1be4
git-subtree-split: a71790070d
2026-07-29 09:04:52 +09:00

193 lines
6.4 KiB
JavaScript

#!/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 <out.optimized.glb> <out.ktx2.glb>
* 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 <input.glb> [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 <input.glb>');
}
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 <input.glb> [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);
});