Code4Scene / scripts /build_harbor_glb.mjs
KoeYe's picture
Claude Opus 5.5
Industrial Harbor card on the same live three.js viewer as the other cases
e998ba1
Raw History Blame Contribute Delete
6.23 kB
// One GLB of GPT-6 Astra's Industrial Harbor for the homepage card, assembled the way SimWorld-AI/industrial-harbor-viewer's
// web/viewer.js draws it: scenes/industrial-harbor-astra-max.json places 3,806 instances of 222 library meshes; a mesh GLB
// has one primitive per material slot (traversal order = slot index), an instance's slots[p] indexes data.materials, the
// instance matrix is compose(pos, quat, scale) applied to the primitive's geometry alone, and water/ocean sits 2 cm lower.
// Materials are the full library's; their textures are KTX2 (Basis), which the site's loader cannot read, so each is
// transcoded here with three r160's Basis transcoder (from the smallest mip level at least 512 px) and stored as PNG.
// The harbor's UE export could not be used: baking its 3,949 actors ran serv6's shared GPUs out of memory (2026-10-04).
//
// node scripts/build_harbor_glb.mjs <scene.json> <library/web dir> <out.glb>
// then compress as website/compress_glb.sh does for Text-to-Scene scenes (meshopt, 512 px WebP, instancing) and pack with
// scripts/harbor_live.py. Needs @gltf-transform/{core,extensions,functions}, draco3dgltf, sharp and three@0.160.0.
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join } from 'node:path';
import { NodeIO, Document } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { mergeDocuments, prune, unpartition } from '@gltf-transform/functions';
import draco3d from 'draco3dgltf';
import sharp from 'sharp';
const [scenePath, lib, out] = process.argv.slice(2);
const data = JSON.parse(readFileSync(scenePath, 'utf8'));
const meshManifest = JSON.parse(readFileSync(join(lib, 'meshes/manifest.json'), 'utf8')).assets;
const matManifest = JSON.parse(readFileSync(join(lib, 'materials/manifest.json'), 'utf8')).assets;
const NOT_GROUND = /water|ocean/i; // viewer.js: these sit 2 cm lower so the ground does not z-fight them
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ 'draco3d.decoder': await draco3d.createDecoderModule() });
const doc = new Document();
doc.createBuffer();
const scene = doc.createScene('Industrial Harbor');
// the first material of each library material GLB, by data.materials index
const materials = [];
for (const path of data.materials) {
const src = await io.read(join(lib, 'materials', matManifest[path].file));
const map = mergeDocuments(doc, src);
const m = src.getRoot().listMaterials()[0];
materials.push(m ? map.get(m) : null);
}
const fallback = doc.createMaterial('Fallback').setBaseColorFactor([0.604, 0.627, 0.678, 1]).setRoughnessFactor(0.85).setMetallicFactor(0);
let placed = 0;
for (const group of data.instances) {
const path = data.meshes[group.mesh];
const src = await io.read(join(lib, 'meshes', meshManifest[path].file));
const map = mergeDocuments(doc, src);
const prims = []; // primitives in traversal order, as viewer.js's primitivesOf()
for (const s of src.getRoot().listScenes()) s.traverse((n) => { const m = n.getMesh(); if (m) prims.push(...m.listPrimitives().map((p) => map.get(p))); });
if (!prims.length) continue;
const meshes = new Map(); // one mesh per distinct slot assignment in this group
group.labels.forEach((label, i) => {
const slots = prims.map((_, p) => { const s = (group.slots[i] || [])[p]; return s != null && s >= 0 ? s : -1; });
const key = slots.join(',');
if (!meshes.has(key)) {
const mesh = doc.createMesh(path.split('.').pop());
prims.forEach((p, k) => mesh.addPrimitive(p.clone().setMaterial(slots[k] >= 0 && materials[slots[k]] ? materials[slots[k]] : fallback)));
meshes.set(key, mesh);
}
const pos = group.pos.slice(i * 3, i * 3 + 3);
if (NOT_GROUND.test(label || '')) pos[1] -= 0.02;
scene.addChild(doc.createNode(label).setMesh(meshes.get(key)).setTranslation(pos)
.setRotation(group.quat.slice(i * 4, i * 4 + 4)).setScale(group.scale.slice(i * 3, i * 3 + 3)));
placed++;
});
}
// the merged sources' own scenes are not used; prune() then drops their nodes and the source meshes
for (const s of doc.getRoot().listScenes()) if (s !== scene) s.dispose();
await doc.transform(prune(), unpartition());
// KTX2 -> PNG (RGBA32 = Basis transcoder format 13)
const require = createRequire(import.meta.url);
// three's package is "type": "module", so its UMD transcoder is evaluated as a script rather than required
const basisJs = require.resolve('three/examples/jsm/libs/basis/basis_transcoder.js');
const BASIS = new Function('require', '__dirname', `${readFileSync(basisJs, 'utf8')}\nreturn BASIS;`)(require, join(basisJs, '..'));
const basis = await BASIS({ wasmBinary: readFileSync(require.resolve('three/examples/jsm/libs/basis/basis_transcoder.wasm')) });
basis.initializeBasis();
let transcoded = 0;
for (const tex of doc.getRoot().listTextures()) {
if (tex.getMimeType() !== 'image/ktx2') continue;
const k = new basis.KTX2File(tex.getImage());
if (!k.isValid() || !k.startTranscoding()) throw new Error(`bad KTX2: ${tex.getName()}`);
let level = 0;
const dim = (l) => { const i = k.getImageLevelInfo(l, 0, 0); return Math.max(i.origWidth, i.origHeight); };
while (level + 1 < k.getLevels() && dim(level + 1) >= 512) level++;
const info = k.getImageLevelInfo(level, 0, 0);
const dst = new Uint8Array(k.getImageTranscodedSizeInBytes(level, 0, 0, 13));
if (!k.transcodeImage(dst, level, 0, 0, 13, 0, -1, -1)) throw new Error(`transcode failed: ${tex.getName()}`);
k.close(); k.delete();
tex.setImage(await sharp(Buffer.from(dst), { raw: { width: info.origWidth, height: info.origHeight, channels: 4 } }).png().toBuffer()).setMimeType('image/png');
transcoded++;
}
for (const e of doc.getRoot().listExtensionsUsed()) if (e.extensionName === 'KHR_texture_basisu') e.dispose();
console.log(`transcoded ${transcoded} KTX2 textures to PNG`);
doc.getRoot().setDefaultScene(scene);
for (const e of doc.getRoot().listExtensionsUsed()) if (e.extensionName === 'KHR_draco_mesh_compression') e.dispose();
await io.write(out, doc);
console.log(`placed ${placed} instances of ${data.instances.length} meshes, ${data.materials.length} materials -> ${out}`);