Three.js viewer supporting glb/gltf/obj/fbx/dae/ifc/ply via both server (SSR) and drag&drop (CSR) paths. - Streaming OBJ parser (src/viewer/objStream.ts) for files past the V8 max string length (>~1GB text) that OBJLoader can't handle; indexed geometry, per-vertex color from MTL Kd, float64 recenter baked in. - In-viewer float64 recenter (objRecenter.ts) for huge CAD/survey coordinates (~1e8) so float32 vertex buffers keep precision (no cracked faces). - Z-up right-handed world; Y-up formats rotated on load. - OBJ+MTL+texture drag&drop (LoadingManager URL-modifier maps dropped images). - OrbitControls ground-plane panning (road/rail alignment workflow). - UI: Zoom Fit, perspective/orthographic toggle, feature-edge outline. - DoubleSide for CAD OBJ; PLY mesh + point-cloud support. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
193 lines
6.4 KiB
JavaScript
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);
|
|
});
|