@jgengine/assets 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/download.js CHANGED
@@ -29,6 +29,37 @@ export function boundedExtractFilter(matches) {
29
29
  return true;
30
30
  };
31
31
  }
32
+ const PACK_IMAGE_EXT = /\.(png|jpe?g|webp)$/i;
33
+ /** Flatten a glTF image URI ("../Textures/color%20map.png") to its decoded basename. */
34
+ function imageUriBasename(uri) {
35
+ const decoded = (() => {
36
+ try {
37
+ return decodeURIComponent(uri);
38
+ }
39
+ catch {
40
+ return uri;
41
+ }
42
+ })();
43
+ const clean = decoded.replace(/\\/g, "/");
44
+ return clean.split("/").pop() ?? clean;
45
+ }
46
+ /**
47
+ * Rewrite external image URIs to flat basenames so a model always finds its
48
+ * texture beside itself in the pack dir, no matter how the source archive
49
+ * nested it ("Textures/x.png", "../texture/x.png", "x.png" all become "x.png").
50
+ * Returns the basenames of every external image the model references.
51
+ */
52
+ function normalizeGltfImageUris(json) {
53
+ const referenced = [];
54
+ for (const image of json.images ?? []) {
55
+ if (image.uri === undefined || image.uri.startsWith("data:"))
56
+ continue;
57
+ const base = imageUriBasename(image.uri);
58
+ image.uri = base;
59
+ referenced.push(base);
60
+ }
61
+ return referenced;
62
+ }
32
63
  function resolveRelative(path, pageUrl) {
33
64
  try {
34
65
  return new URL(path, pageUrl).toString();
@@ -194,23 +225,7 @@ function dedupeByBasename(archive, pattern) {
194
225
  }
195
226
  return Array.from(byName, ([file, bytes]) => ({ file, bytes })).sort((a, b) => a.file.localeCompare(b.file));
196
227
  }
197
- /**
198
- * Pack a `.gltf` + optional external `.bin` into a single `.glb` so reindex /
199
- * the shell only ever deal in one-file models. Strips buffer `uri` fields so
200
- * the binary chunk is self-contained. External image URIs stay as-is (loaders
201
- * resolve relative to the catalog URL only for GLB-embedded images).
202
- * @internal
203
- */
204
- export function packGltfToGlb(gltfBytes, binBytes) {
205
- const json = JSON.parse(new TextDecoder().decode(gltfBytes));
206
- if (Array.isArray(json.buffers)) {
207
- for (const buffer of json.buffers) {
208
- delete buffer.uri;
209
- if (binBytes !== undefined)
210
- buffer.byteLength = binBytes.byteLength;
211
- }
212
- }
213
- const jsonText = JSON.stringify(json);
228
+ function buildGlb(jsonText, binBytes) {
214
229
  const jsonPad = (4 - (jsonText.length % 4)) % 4;
215
230
  const jsonChunk = new Uint8Array(jsonText.length + jsonPad);
216
231
  new TextEncoder().encodeInto(jsonText, jsonChunk);
@@ -239,22 +254,109 @@ export function packGltfToGlb(gltfBytes, binBytes) {
239
254
  }
240
255
  return out;
241
256
  }
257
+ /** Parse a `.glb` container into its JSON document and optional BIN chunk. Returns null for non-glb bytes. */
258
+ function parseGlb(bytes) {
259
+ if (bytes.byteLength < 20)
260
+ return null;
261
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
262
+ if (view.getUint32(0, true) !== 0x46546c67)
263
+ return null;
264
+ let offset = 12;
265
+ let json;
266
+ let bin;
267
+ while (offset + 8 <= bytes.byteLength) {
268
+ const length = view.getUint32(offset, true);
269
+ const kind = view.getUint32(offset + 4, true);
270
+ const body = bytes.subarray(offset + 8, offset + 8 + length);
271
+ if (kind === 0x4e4f534a) {
272
+ try {
273
+ json = JSON.parse(new TextDecoder().decode(body));
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ }
279
+ else if (kind === 0x004e4942) {
280
+ bin = body;
281
+ }
282
+ offset += 8 + length;
283
+ }
284
+ if (json === undefined)
285
+ return null;
286
+ return bin === undefined ? { json } : { json, bin };
287
+ }
288
+ /**
289
+ * External image basenames a packed `.glb` still references (its textures must
290
+ * sit beside it in the pack dir). Empty for self-contained models.
291
+ * @internal
292
+ */
293
+ export function externalGlbImages(bytes) {
294
+ const chunks = parseGlb(bytes);
295
+ if (chunks === null)
296
+ return [];
297
+ return (chunks.json.images ?? [])
298
+ .map((image) => image.uri)
299
+ .filter((uri) => uri !== undefined && !uri.startsWith("data:"))
300
+ .map((uri) => imageUriBasename(uri));
301
+ }
302
+ /** Rewrite a native `.glb`'s external image URIs to flat basenames; returns the input untouched when nothing changes. */
303
+ function normalizeGlbImageUris(bytes) {
304
+ const chunks = parseGlb(bytes);
305
+ if (chunks === null)
306
+ return { bytes, referenced: [] };
307
+ const before = JSON.stringify(chunks.json.images ?? []);
308
+ const referenced = normalizeGltfImageUris(chunks.json);
309
+ if (referenced.length === 0 || JSON.stringify(chunks.json.images ?? []) === before) {
310
+ return { bytes, referenced };
311
+ }
312
+ return { bytes: buildGlb(JSON.stringify(chunks.json), chunks.bin), referenced };
313
+ }
242
314
  /**
243
- * Pull every GLB out of an archive. Also converts co-located `.gltf` + `.bin`
244
- * pairs (Quaternius Standard packs on OpenGameArt) into `.glb` so the catalog
245
- * stays one-file-per-model. GLB wins when both formats share a basename.
315
+ * Pack a `.gltf` + optional external `.bin` into a single `.glb` so reindex /
316
+ * the shell only ever deal in one-file models. Strips buffer `uri` fields so
317
+ * the binary chunk is self-contained; external image URIs are flattened to
318
+ * basenames so the pack ships each referenced texture beside the models
319
+ * (loaders resolve the relative URI against the model's own URL).
320
+ * @internal
321
+ */
322
+ export function packGltfToGlb(gltfBytes, binBytes) {
323
+ const json = JSON.parse(new TextDecoder().decode(gltfBytes));
324
+ if (Array.isArray(json.buffers)) {
325
+ for (const buffer of json.buffers) {
326
+ delete buffer.uri;
327
+ if (binBytes !== undefined)
328
+ buffer.byteLength = binBytes.byteLength;
329
+ }
330
+ }
331
+ normalizeGltfImageUris(json);
332
+ return buildGlb(JSON.stringify(json), binBytes);
333
+ }
334
+ /**
335
+ * Pull every GLB out of an archive, plus the textures those models reference.
336
+ * Converts co-located `.gltf` + `.bin` pairs (Quaternius Standard packs on
337
+ * OpenGameArt, KayKit bits packs) into `.glb` so the catalog stays
338
+ * one-file-per-model; GLB wins when both formats share a basename. External
339
+ * image URIs are flattened to basenames and the matching archive images are
340
+ * returned for writing beside the models — a pulled pack renders textured, not
341
+ * as white clay (#1005).
246
342
  * @internal
247
343
  */
248
344
  export function extractGlbs(archive) {
249
345
  const entries = unzipSync(archive, {
250
- filter: boundedExtractFilter((file) => /\.(glb|gltf|bin)$/i.test(file.name)),
346
+ filter: boundedExtractFilter((file) => /\.(glb|gltf|bin)$/i.test(file.name) || PACK_IMAGE_EXT.test(file.name)),
251
347
  });
252
348
  const byDirBase = new Map();
349
+ const imagesByBase = new Map();
253
350
  for (const [path, bytes] of Object.entries(entries)) {
254
351
  const parts = path.replace(/\\/g, "/").split("/");
255
352
  const file = parts.pop();
256
353
  if (file === undefined || file.length === 0)
257
354
  continue;
355
+ if (PACK_IMAGE_EXT.test(file)) {
356
+ if (!imagesByBase.has(file))
357
+ imagesByBase.set(file, bytes);
358
+ continue;
359
+ }
258
360
  const dir = parts.join("/");
259
361
  // KayKit ships some files as `name.gltf.glb` — strip both suffixes for a clean id.
260
362
  const base = file.replace(/\.gltf\.glb$/i, "").replace(/\.(glb|gltf|bin)$/i, "");
@@ -275,20 +377,32 @@ export function extractGlbs(archive) {
275
377
  byDirBase.set(key, slot);
276
378
  }
277
379
  const byName = new Map();
380
+ const referenced = new Set();
278
381
  for (const [key, slot] of byDirBase) {
279
382
  const base = key.split("\0")[1];
280
383
  const file = `${base}.glb`;
384
+ if (byName.has(file))
385
+ continue;
281
386
  if (slot.glb !== undefined) {
282
- if (!byName.has(file))
283
- byName.set(file, slot.glb);
387
+ const normalized = normalizeGlbImageUris(slot.glb);
388
+ byName.set(file, normalized.bytes);
389
+ for (const image of normalized.referenced)
390
+ referenced.add(image);
284
391
  continue;
285
392
  }
286
393
  if (slot.gltf !== undefined) {
287
- if (!byName.has(file))
288
- byName.set(file, packGltfToGlb(slot.gltf, slot.bin));
394
+ const packed = packGltfToGlb(slot.gltf, slot.bin);
395
+ byName.set(file, packed);
396
+ for (const image of externalGlbImages(packed))
397
+ referenced.add(image);
289
398
  }
290
399
  }
291
- return Array.from(byName, ([file, bytes]) => ({ file, bytes })).sort((a, b) => a.file.localeCompare(b.file));
400
+ const models = Array.from(byName, ([file, bytes]) => ({ file, bytes })).sort((a, b) => a.file.localeCompare(b.file));
401
+ const images = Array.from(referenced)
402
+ .filter((file) => imagesByBase.has(file))
403
+ .map((file) => ({ file, bytes: imagesByBase.get(file) }))
404
+ .sort((a, b) => a.file.localeCompare(b.file));
405
+ return { models, images };
292
406
  }
293
407
  /** Pulls every SVG/PNG out of a sprite/icon-pack archive, deduped by basename regardless of nesting depth.
294
408
  * @internal
@@ -296,23 +410,6 @@ export function extractGlbs(archive) {
296
410
  export function extractSpriteFiles(archive) {
297
411
  return dedupeByBasename(archive, /\.(svg|png)$/i);
298
412
  }
299
- const TEXTURE_ENTRY = /(?:^|\/)(Textures\/[^/]+)$/i;
300
- /** @internal */
301
- export function extractTextures(archive) {
302
- const entries = unzipSync(archive, {
303
- filter: boundedExtractFilter((file) => TEXTURE_ENTRY.test(file.name)),
304
- });
305
- const byRel = new Map();
306
- for (const [path, bytes] of Object.entries(entries)) {
307
- const match = path.match(TEXTURE_ENTRY);
308
- if (match === null)
309
- continue;
310
- const rel = match[1];
311
- if (!byRel.has(rel))
312
- byRel.set(rel, bytes);
313
- }
314
- return Array.from(byRel, ([file, bytes]) => ({ file, bytes })).sort((a, b) => a.file.localeCompare(b.file));
315
- }
316
413
  /** @internal */
317
414
  export async function sha256Hex(bytes) {
318
415
  const buffer = new ArrayBuffer(bytes.byteLength);