clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

main
marbles / tools / lib / pack.ts
pack.ts 10.7K
1/**2 * Pack import core: merge a Kenney kit's per-piece GLBs into a single GLB and3 * derive its `PackManifest`. Kept out of the CLI so the steps stay testable and4 * the CLI is only argument parsing plus reporting.5 */6import {7 Document,8 getBounds,9 Logger,10 type Node,11 NodeIO,12 Primitive,13 type Scene,14 Verbosity,15} from "@gltf-transform/core";16import { KHRTextureTransform } from "@gltf-transform/extensions";17import {18 dedup,19 getGLPrimitiveCount,20 mergeDocuments,21 prune,22 unpartition,23} from "@gltf-transform/functions";24import { basename, join } from "@std/path";25import { classify } from "../../src/shared/classify.ts";26import type {27 ManifestPiece,28 PackManifest,29 Vec3,30} from "../../src/shared/types.ts";3132/**33 * Extent overshoot tolerated before a piece claims another grid cell. Kenney's34 * helixes exceed their nominal footprint by ~1e-3 units (e.g. 6.0022 for a35 * 6-cell sweep); a bare `Math.ceil` would bill them a whole extra cell.36 */37const FOOTPRINT_EPSILON = 0.01;3839/** Marble Kit is the track vocabulary — every piece must classify. */40export const STRICT_SLUG = "marble-kit";41/** Piece count the Marble Kit is contractually expected to ship. */42export const STRICT_PIECE_COUNT = 162;4344export interface PackResult {45 manifest: PackManifest;46 inputBytes: number;47 outputBytes: number;48 texturesBefore: number;49 texturesAfter: number;50}5152/** "Marble Kit" -> "marble-kit". */53export function slugify(pack: string): string {54 return pack.trim().toLowerCase().replace(/\s+/g, "-");55}5657function createIO(): NodeIO {58 // Kenney GLBs reference `Textures/colormap.png` externally, so the IO must be59 // able to resolve sibling files — NodeIO.read() does, readBinary() cannot.60 // KHR_texture_transform is registered so its (identity) payload survives the61 // round-trip instead of being dropped as an unknown extension.62 return new NodeIO()63 .registerExtensions([KHRTextureTransform])64 .setLogger(new Logger(Verbosity.ERROR));65}6667function listGlbFiles(dir: string): string[] {68 let entries: Deno.DirEntry[];69 try {70 entries = [...Deno.readDirSync(dir)];71 } catch {72 return [];73 }74 return entries75 .filter((e) => e.isFile && e.name.toLowerCase().endsWith(".glb"))76 .map((e) => e.name)77 .sort();78}7980/**81 * Locate a pack's GLB directory. Most kits use `Models/GLB format`; Nature Kit82 * ships its .glb files under `Models/GLTF format` instead.83 */84export function findModelDir(sourceRoot: string, pack: string): string {85 const modelsDir = join(sourceRoot, pack, "Models");86 for (const name of ["GLB format", "GLTF format"]) {87 const dir = join(modelsDir, name);88 if (listGlbFiles(dir).length > 0) return dir;89 }90 throw new Error(91 `pack "${pack}": no *.glb files under ${modelsDir} (tried "GLB format", "GLTF format")`,92 );93}9495/** First License.txt line mentioning CC0 wins; otherwise "unknown". */96export function readLicense(sourceRoot: string, pack: string): string {97 let text: string;98 try {99 text = Deno.readTextFileSync(join(sourceRoot, pack, "License.txt"));100 } catch {101 return "unknown";102 }103 for (const line of text.split(/\r?\n/)) {104 if (/\bCC0\b/i.test(line)) return "CC0";105 }106 return "unknown";107}108109function round(n: number): number {110 // Kill -0 and float noise; 1e-6 is far below any meaningful grid dimension.111 const r = Math.round(n * 1e6) / 1e6;112 return Object.is(r, -0) ? 0 : r;113}114115/** Grid cells spanned by an extent, tolerating sub-hundredth overshoot. */116export function footprintCells(extent: number): number {117 return Math.max(1, Math.ceil(extent - FOOTPRINT_EPSILON));118}119120function descendants(node: Node): Node[] {121 const out: Node[] = [node];122 for (let i = 0; i < out.length; i++) {123 out.push(...out[i].listChildren());124 }125 return out;126}127128/**129 * Drop the node so its world AABB rests on y=0. Kenney authors most pieces this130 * way already; the six `marble-*` spheres are origin-centred, and Nature Kit131 * sinks its base plates below y=0. `Placement` documents piece origins as132 * bottom-centre, so normalising here keeps that contract true for every piece.133 */134function groundNode(node: Node): void {135 const minY = getBounds(node).min[1];136 if (minY === 0) return;137 const t = node.getTranslation();138 node.setTranslation([t[0], t[1] - minY, t[2]]);139}140141function isTriangleMode(mode: number): boolean {142 return mode === Primitive.Mode.TRIANGLES ||143 mode === Primitive.Mode.TRIANGLE_STRIP ||144 mode === Primitive.Mode.TRIANGLE_FAN;145}146147/** World AABB plus vertex/triangle totals over the node's whole subtree. */148function measure(node: Node): {149 bboxMin: Vec3;150 bboxMax: Vec3;151 vertices: number;152 triangles: number;153} {154 const bounds = getBounds(node);155 let vertices = 0;156 let triangles = 0;157 for (const n of descendants(node)) {158 const mesh = n.getMesh();159 if (!mesh) continue;160 for (const prim of mesh.listPrimitives()) {161 vertices += prim.getAttribute("POSITION")?.getCount() ?? 0;162 if (isTriangleMode(prim.getMode())) {163 triangles += getGLPrimitiveCount(prim);164 }165 }166 }167 return {168 bboxMin: [169 round(bounds.min[0]),170 round(bounds.min[1]),171 round(bounds.min[2]),172 ],173 bboxMax: [174 round(bounds.max[0]),175 round(bounds.max[1]),176 round(bounds.max[2]),177 ],178 vertices,179 triangles,180 };181}182183interface MergeResult {184 doc: Document;185 order: { id: string; node: Node }[];186 texturesBefore: number;187 inputBytes: number;188}189190/**191 * Read every GLB in `modelDir` (sorted by filename) into one Document whose192 * single scene has exactly one child per source file, named after the file.193 */194async function mergePack(modelDir: string, slug: string): Promise<MergeResult> {195 const io = createIO();196 const files = listGlbFiles(modelDir);197 const doc = new Document().setLogger(new Logger(Verbosity.ERROR));198 const scene = doc.createScene(slug);199200 const order: { id: string; node: Node }[] = [];201 let texturesBefore = 0;202 let inputBytes = 0;203204 for (const file of files) {205 const id = basename(file, ".glb");206 const path = join(modelDir, file);207 inputBytes += Deno.statSync(path).size;208209 const src = await io.read(path);210 const srcRoot = src.getRoot();211 texturesBefore += srcRoot.listTextures().length;212213 const srcScene = srcRoot.getDefaultScene() ?? srcRoot.listScenes()[0];214 if (!srcScene) throw new Error(`${file}: contains no scene`);215216 const map = mergeDocuments(doc, src);217 const copiedScenes = srcRoot218 .listScenes()219 .map((s) => map.get(s) as Scene | undefined)220 .filter((s): s is Scene => !!s);221 const copied = map.get(srcScene) as Scene;222223 const children = copied.listChildren();224 if (children.length === 0) throw new Error(`${file}: scene has no nodes`);225226 let node: Node;227 if (children.length === 1) {228 node = children[0];229 node.setName(id);230 } else {231 // Several roots (or a mis-authored name): wrap them so the piece is232 // addressable as a single node named `<id>`.233 node = doc.createNode(id);234 for (const child of children) node.addChild(child);235 }236237 scene.addChild(node);238 for (const s of copiedScenes) {239 s.removeChild(node);240 s.dispose();241 }242 order.push({ id, node });243 }244245 doc.getRoot().setDefaultScene(scene);246 return { doc, order, texturesBefore, inputBytes };247}248249/**250 * Import one pack: merge, dedup/prune, measure, classify, and write251 * `<out>/<slug>.glb` + `<out>/<slug>.manifest.json`.252 */253export async function importPack(254 sourceRoot: string,255 pack: string,256 out: string,257): Promise<PackResult> {258 const slug = slugify(pack);259 const modelDir = findModelDir(sourceRoot, pack);260 const { doc, order, texturesBefore, inputBytes } = await mergePack(261 modelDir,262 slug,263 );264265 // dedup collapses the per-file copies of the shared colormap texture (and266 // identical accessors/materials/meshes); prune drops what merging orphaned.267 // flatten() is deliberately not used: it would dissolve the one-node-per-piece268 // scene structure the manifest and the piece registry depend on.269 // unpartition folds the per-source buffers into one — a GLB allows only one.270 await doc.transform(dedup(), prune(), unpartition());271272 const root = doc.getRoot();273 const scene = root.getDefaultScene();274 if (!scene) throw new Error(`pack "${pack}": merged document lost its scene`);275276 for (const { id, node } of order) {277 if (node.isDisposed()) {278 throw new Error(`pack "${pack}": piece "${id}" was pruned away`);279 }280 }281 if (scene.listChildren().length !== order.length) {282 throw new Error(283 `pack "${pack}": scene has ${scene.listChildren().length} children, expected ${order.length} piece nodes`,284 );285 }286287 const pieces: ManifestPiece[] = [];288 const unknown: string[] = [];289 for (const { id, node } of order) {290 groundNode(node);291 const m = measure(node);292 const classification = classify(id);293 if (classification.family === "unknown") unknown.push(id);294 pieces.push({295 id,296 ...classification,297 bboxMin: m.bboxMin,298 bboxMax: m.bboxMax,299 footprint: [300 footprintCells(m.bboxMax[0] - m.bboxMin[0]),301 footprintCells(m.bboxMax[2] - m.bboxMin[2]),302 ],303 vertices: m.vertices,304 triangles: m.triangles,305 });306 }307308 if (slug === STRICT_SLUG) {309 if (pieces.length !== STRICT_PIECE_COUNT) {310 throw new Error(311 `pack "${pack}": expected ${STRICT_PIECE_COUNT} pieces, found ${pieces.length} in ${modelDir}`,312 );313 }314 if (unknown.length > 0) {315 throw new Error(316 `pack "${pack}": ${unknown.length} piece(s) classify to family "unknown" — ` +317 `classify.ts needs a rule for: ${unknown.join(", ")}`,318 );319 }320 }321322 const glbPath = join(out, `${slug}.glb`);323 const manifestPath = join(out, `${slug}.manifest.json`);324 await Deno.mkdir(out, { recursive: true });325 const glb = await createIO().writeBinary(doc);326 await Deno.writeFile(glbPath, glb);327328 const manifest: PackManifest = {329 version: 1,330 pack,331 slug,332 license: readLicense(sourceRoot, pack),333 glbPath,334 glbBytes: glb.byteLength,335 pieceCount: pieces.length,336 pieces,337 };338 await Deno.writeTextFile(339 manifestPath,340 JSON.stringify(manifest, null, 2) + "\n",341 );342343 if (slug === STRICT_SLUG) {344 await copyVariationTextures(modelDir, out);345 }346347 return {348 manifest,349 inputBytes,350 outputBytes: glb.byteLength,351 texturesBefore,352 texturesAfter: root.listTextures().length,353 };354}355356/** Marble Kit ships three alternate palettes next to its models. */357async function copyVariationTextures(358 modelDir: string,359 out: string,360): Promise<void> {361 const src = join(modelDir, "..", "Textures");362 const dest = join(out, "textures");363 await Deno.mkdir(dest, { recursive: true });364 for (365 const name of ["variation-a.png", "variation-b.png", "variation-c.png"]366 ) {367 await Deno.copyFile(join(src, name), join(dest, name));368 }369}