@threenative/assets 0.3.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/index.js ADDED
@@ -0,0 +1,3600 @@
1
+ import { createHash } from 'crypto';
2
+ import { rm, mkdir, writeFile, stat, readFile, realpath, copyFile, readdir, mkdtemp, rename } from 'fs/promises';
3
+ import { createRequire } from 'module';
4
+ import path from 'path';
5
+ import { ImageUtils, NodeIO, Extension, PropertyType, ExtensionProperty, Accessor } from '@gltf-transform/core';
6
+ import { EXTMeshoptCompression, ALL_EXTENSIONS, KHRTextureBasisu } from '@gltf-transform/extensions';
7
+ import { Api } from 'xatlasjs/dist/node/api.mjs';
8
+ import createXAtlasModule from 'xatlasjs/dist/node/xatlas.js';
9
+ import { BufferGeometry, BufferAttribute, Ray, Vector3, DoubleSide } from 'three';
10
+ import { MeshBVH } from 'three-mesh-bvh';
11
+ import { read } from 'ktx-parse';
12
+ import { encodeToKTX2 } from 'ktx2-encoder';
13
+ import { PNG } from 'pngjs';
14
+ import { dedup, prune, simplify, reorder, quantize, listTextureInfo, listTextureSlots, getTextureColorSpace } from '@gltf-transform/functions';
15
+ import { MeshoptSimplifier, MeshoptEncoder, MeshoptDecoder, MeshoptClusterizer } from 'meshoptimizer';
16
+ import { watch } from 'fs';
17
+ import { tmpdir } from 'os';
18
+
19
+ // src/compile.ts
20
+
21
+ // src/png.ts
22
+ var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
23
+ function crc32(value) {
24
+ let crc = 4294967295;
25
+ for (const byte of value) {
26
+ crc ^= byte;
27
+ for (let bit = 0; bit < 8; bit += 1) {
28
+ crc = crc >>> 1 ^ (crc & 1 ? 3988292384 : 0);
29
+ }
30
+ }
31
+ return (crc ^ 4294967295) >>> 0;
32
+ }
33
+ function parsePng(value) {
34
+ if (value.length < PNG_SIGNATURE.length || !value.subarray(0, 8).equals(PNG_SIGNATURE)) {
35
+ return void 0;
36
+ }
37
+ let offset = PNG_SIGNATURE.length;
38
+ let hasHeader = false;
39
+ let hasData = false;
40
+ let width = 0;
41
+ let height = 0;
42
+ let colorType = 0;
43
+ let hasTransparencyChunk = false;
44
+ while (offset + 12 <= value.length) {
45
+ const length2 = value.readUInt32BE(offset);
46
+ const chunkEnd = offset + 12 + length2;
47
+ if (chunkEnd > value.length) return void 0;
48
+ const type = value.toString("ascii", offset + 4, offset + 8);
49
+ const chunk = value.subarray(offset + 4, offset + 8 + length2);
50
+ if (crc32(chunk) !== value.readUInt32BE(offset + 8 + length2)) return void 0;
51
+ if (type === "IHDR") {
52
+ if (hasHeader || length2 !== 13) return void 0;
53
+ width = value.readUInt32BE(offset + 8);
54
+ height = value.readUInt32BE(offset + 12);
55
+ colorType = value.readUInt8(offset + 17);
56
+ if (width === 0 || height === 0) return void 0;
57
+ hasHeader = true;
58
+ } else if (type === "IDAT") {
59
+ hasData = true;
60
+ } else if (type === "tRNS") {
61
+ hasTransparencyChunk = true;
62
+ } else if (type === "IEND") {
63
+ if (length2 !== 0 || !hasHeader || !hasData || chunkEnd !== value.length) return void 0;
64
+ return {
65
+ width,
66
+ height,
67
+ hasAlpha: colorType === 4 || colorType === 6 || hasTransparencyChunk
68
+ };
69
+ }
70
+ offset = chunkEnd;
71
+ }
72
+ return void 0;
73
+ }
74
+
75
+ // src/health.ts
76
+ var UNKNOWN_LICENSE = "unknown";
77
+ var MODE_TRIANGLES = 4;
78
+ var MODE_TRIANGLE_STRIPS = 5;
79
+ var MODE_TRIANGLE_FANS = 6;
80
+ function messageOf(error) {
81
+ return error instanceof Error ? error.message : String(error);
82
+ }
83
+ function isPowerOfTwo(value) {
84
+ return value > 0 && (value & value - 1) === 0;
85
+ }
86
+ function carriedLicense(asset) {
87
+ const copyright = asset.copyright?.trim();
88
+ return copyright !== void 0 && copyright.length > 0 ? copyright : UNKNOWN_LICENSE;
89
+ }
90
+ function marksCollider(name) {
91
+ return /collider/iu.test(name) || /(?:^|[-_. ])col(?:only|convcolonly|convcol)?$/iu.test(name);
92
+ }
93
+ function sniffImageMime(bytes) {
94
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
95
+ return "image/jpeg";
96
+ }
97
+ return parsePng(bytes) === void 0 ? void 0 : "image/png";
98
+ }
99
+ function textureStats(bytes, mimeType) {
100
+ const mime = mimeType ?? sniffImageMime(bytes);
101
+ let width = 0;
102
+ let height = 0;
103
+ let alpha = false;
104
+ if (mime !== void 0) {
105
+ if (mime === "image/png") {
106
+ const png = parsePng(bytes);
107
+ if (png !== void 0) {
108
+ ({ height, width } = png);
109
+ alpha = png.hasAlpha;
110
+ }
111
+ } else {
112
+ try {
113
+ const size = ImageUtils.getSize(bytes, mime);
114
+ if (size !== null) [width, height] = size;
115
+ } catch {
116
+ }
117
+ }
118
+ }
119
+ return {
120
+ alpha,
121
+ height,
122
+ powerOfTwo: isPowerOfTwo(width) && isPowerOfTwo(height),
123
+ width
124
+ };
125
+ }
126
+ async function parseModel(data, logicalPath) {
127
+ const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
128
+ try {
129
+ if (data.subarray(0, 4).toString("ascii") === "glTF") {
130
+ return await io.readJSON(await io.binaryToJSON(data));
131
+ }
132
+ const json = JSON.parse(data.toString("utf8"));
133
+ return await io.readJSON({ json, resources: {} });
134
+ } catch (error) {
135
+ throw new Error(
136
+ `TN_ASSETS_MODEL_UNREADABLE: could not parse '${logicalPath}' for the health report: ${messageOf(error)}. External buffer or image URIs are not measured; use a self-contained .glb.`
137
+ );
138
+ }
139
+ }
140
+ function primitiveTriangles(mode, indices, vertices) {
141
+ const drawn = indices ?? vertices ?? 0;
142
+ if (mode === MODE_TRIANGLES) return Math.floor(drawn / 3);
143
+ if (mode === MODE_TRIANGLE_STRIPS || mode === MODE_TRIANGLE_FANS) return Math.max(0, drawn - 2);
144
+ return 0;
145
+ }
146
+ async function parseModelStats(data, logicalPath) {
147
+ const root = (await parseModel(data, logicalPath)).getRoot();
148
+ let triangles = 0;
149
+ for (const mesh of root.listMeshes()) {
150
+ for (const prim of mesh.listPrimitives()) {
151
+ triangles += primitiveTriangles(
152
+ prim.getMode(),
153
+ prim.getIndices()?.getCount(),
154
+ prim.getAttribute("POSITION")?.getCount()
155
+ );
156
+ }
157
+ }
158
+ const colliderPresent = [...root.listNodes(), ...root.listMeshes()].some(
159
+ (property) => marksCollider(property.getName() ?? "")
160
+ ) || root.listScenes().some((scene) => marksCollider(scene.getName() ?? ""));
161
+ const rootNodes = new Set(root.listScenes().flatMap((scene) => scene.listChildren()));
162
+ let rootMotion = false;
163
+ for (const animation of root.listAnimations()) {
164
+ for (const channel of animation.listChannels()) {
165
+ const targetNode = channel.getTargetNode();
166
+ if (channel.getTargetPath() === "translation" && targetNode && rootNodes.has(targetNode)) {
167
+ rootMotion = true;
168
+ }
169
+ }
170
+ }
171
+ const textures = root.listTextures().map((texture) => {
172
+ const image = texture.getImage();
173
+ const bytes = image ? Buffer.from(image.buffer, image.byteOffset, image.byteLength) : Buffer.alloc(0);
174
+ return textureStats(bytes, texture.getMimeType());
175
+ });
176
+ return {
177
+ license: carriedLicense(root.getAsset()),
178
+ model: {
179
+ animationClips: root.listAnimations().length,
180
+ colliderPresent,
181
+ materials: root.listMaterials().length,
182
+ rootMotion,
183
+ textures,
184
+ triangles
185
+ }
186
+ };
187
+ }
188
+ function budgeted(draft, id, asset, value, noun, target) {
189
+ const exceeded = target !== void 0 && value > target;
190
+ draft.findings.push({
191
+ asset,
192
+ grade: exceeded ? "fail" : "ok",
193
+ id,
194
+ ...target === void 0 ? {} : { target },
195
+ message: `${value} ${noun}${exceeded ? `, above the declared target of ${target}` : ""}`,
196
+ value
197
+ });
198
+ }
199
+ function textureFindings(draft, asset, subject, texture, targets) {
200
+ const readable = texture.width > 0 && texture.height > 0;
201
+ const dimensionText = readable ? `${texture.width}x${texture.height}` : "unknown";
202
+ const exceeded = targets.maxTextureDimension !== void 0 && readable && Math.max(texture.width, texture.height) > targets.maxTextureDimension;
203
+ draft.findings.push({
204
+ asset,
205
+ grade: exceeded ? "fail" : "ok",
206
+ id: "texture.dimensions",
207
+ ...targets.maxTextureDimension === void 0 ? {} : { target: targets.maxTextureDimension },
208
+ message: readable ? `${dimensionText} pixels${exceeded ? `, above the declared target of ${targets.maxTextureDimension}` : ""}` : "dimensions unknown",
209
+ subject,
210
+ value: dimensionText
211
+ });
212
+ if (!readable) return;
213
+ draft.findings.push({
214
+ asset,
215
+ grade: texture.powerOfTwo ? "ok" : "warn",
216
+ id: "texture.powerOfTwo",
217
+ message: texture.powerOfTwo ? "power-of-two dimensions" : "dimensions are not power-of-two",
218
+ subject,
219
+ value: texture.powerOfTwo
220
+ });
221
+ draft.findings.push({
222
+ asset,
223
+ grade: "ok",
224
+ id: "texture.alpha",
225
+ message: texture.alpha ? "alpha channel present" : "no alpha channel",
226
+ subject,
227
+ value: texture.alpha
228
+ });
229
+ }
230
+ async function measureAsset(draft, input, targets) {
231
+ const kind = classify(input.logicalPath);
232
+ if (kind === "model") {
233
+ const { license, model } = await parseModelStats(input.data, input.logicalPath);
234
+ budgeted(
235
+ draft,
236
+ "triangles",
237
+ input.logicalPath,
238
+ model.triangles,
239
+ "triangles",
240
+ targets.maxTriangles
241
+ );
242
+ budgeted(
243
+ draft,
244
+ "materials",
245
+ input.logicalPath,
246
+ model.materials,
247
+ "material(s)",
248
+ targets.maxMaterials
249
+ );
250
+ draft.findings.push({
251
+ asset: input.logicalPath,
252
+ grade: model.colliderPresent ? "ok" : "warn",
253
+ id: "collider",
254
+ message: model.colliderPresent ? "collider node present" : "no collider found (name a node or mesh '-col' or 'collider')",
255
+ value: model.colliderPresent
256
+ });
257
+ draft.findings.push({
258
+ asset: input.logicalPath,
259
+ grade: "ok",
260
+ id: "rootMotion",
261
+ message: model.rootMotion ? "root motion detected in an animation clip" : "no root motion detected",
262
+ value: model.rootMotion
263
+ });
264
+ model.textures.forEach((texture, index) => {
265
+ textureFindings(draft, input.logicalPath, `tex${index}`, texture, targets);
266
+ });
267
+ return { asset: input.logicalPath, kind, license, model };
268
+ }
269
+ if (kind === "texture") {
270
+ const texture = textureStats(input.data);
271
+ textureFindings(draft, input.logicalPath, "tex0", texture, targets);
272
+ return { asset: input.logicalPath, kind, license: UNKNOWN_LICENSE, texture };
273
+ }
274
+ return { asset: input.logicalPath, kind, license: UNKNOWN_LICENSE };
275
+ }
276
+ async function runHealthReport(inputs, targets = {}) {
277
+ const draft = { entries: [], findings: [] };
278
+ for (const input of inputs) {
279
+ const entry = await measureAsset(draft, input, targets);
280
+ draft.entries.push(entry);
281
+ draft.findings.push({
282
+ asset: input.logicalPath,
283
+ grade: entry.license === UNKNOWN_LICENSE ? "warn" : "ok",
284
+ id: "license",
285
+ message: `license: ${entry.license}`,
286
+ value: entry.license
287
+ });
288
+ }
289
+ const summary = { fail: 0, ok: 0, warn: 0 };
290
+ for (const finding of draft.findings) summary[finding.grade] += 1;
291
+ return { ...draft, failed: summary.fail > 0, summary };
292
+ }
293
+ function formatHealthReport(report) {
294
+ const lines = report.findings.map((finding) => {
295
+ const where = finding.subject === void 0 ? finding.asset : `${finding.asset} ${finding.subject}`;
296
+ return `[${finding.grade}] ${where}: ${finding.message}`;
297
+ });
298
+ const { fail, ok, warn } = report.summary;
299
+ lines.push(
300
+ `asset health: ${report.entries.length} asset(s), ${ok} ok, ${warn} warn, ${fail} fail`
301
+ );
302
+ return lines;
303
+ }
304
+ function add(left, right) {
305
+ return [left[0] + right[0], left[1] + right[1], left[2] + right[2]];
306
+ }
307
+ function subtract(left, right) {
308
+ return [left[0] - right[0], left[1] - right[1], left[2] - right[2]];
309
+ }
310
+ function scale(value, amount) {
311
+ return [value[0] * amount, value[1] * amount, value[2] * amount];
312
+ }
313
+ function dot(left, right) {
314
+ return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
315
+ }
316
+ function cross(left, right) {
317
+ return [
318
+ left[1] * right[2] - left[2] * right[1],
319
+ left[2] * right[0] - left[0] * right[2],
320
+ left[0] * right[1] - left[1] * right[0]
321
+ ];
322
+ }
323
+ function length(value) {
324
+ return Math.sqrt(dot(value, value));
325
+ }
326
+ function normalize(value) {
327
+ const magnitude = length(value);
328
+ return magnitude === 0 ? [0, 1, 0] : scale(value, 1 / magnitude);
329
+ }
330
+ function transformPosition(matrix, value) {
331
+ return [
332
+ (matrix[0] ?? 1) * value[0] + (matrix[4] ?? 0) * value[1] + (matrix[8] ?? 0) * value[2] + (matrix[12] ?? 0),
333
+ (matrix[1] ?? 0) * value[0] + (matrix[5] ?? 1) * value[1] + (matrix[9] ?? 0) * value[2] + (matrix[13] ?? 0),
334
+ (matrix[2] ?? 0) * value[0] + (matrix[6] ?? 0) * value[1] + (matrix[10] ?? 1) * value[2] + (matrix[14] ?? 0)
335
+ ];
336
+ }
337
+ function transformDirection(matrix, value) {
338
+ return normalize([
339
+ (matrix[0] ?? 1) * value[0] + (matrix[4] ?? 0) * value[1] + (matrix[8] ?? 0) * value[2],
340
+ (matrix[1] ?? 0) * value[0] + (matrix[5] ?? 1) * value[1] + (matrix[9] ?? 0) * value[2],
341
+ (matrix[2] ?? 0) * value[0] + (matrix[6] ?? 0) * value[1] + (matrix[10] ?? 1) * value[2]
342
+ ]);
343
+ }
344
+ function transformNormal(matrix, value) {
345
+ const column0 = [matrix[0] ?? 1, matrix[1] ?? 0, matrix[2] ?? 0];
346
+ const column1 = [matrix[4] ?? 0, matrix[5] ?? 1, matrix[6] ?? 0];
347
+ const column2 = [matrix[8] ?? 0, matrix[9] ?? 0, matrix[10] ?? 1];
348
+ const determinant = dot(column0, cross(column1, column2));
349
+ const cofactor = add(
350
+ add(scale(cross(column1, column2), value[0]), scale(cross(column2, column0), value[1])),
351
+ scale(cross(column0, column1), value[2])
352
+ );
353
+ return normalize(scale(cofactor, determinant < 0 ? -1 : 1));
354
+ }
355
+ function reachableNodes(document) {
356
+ const nodes = [];
357
+ const seen = /* @__PURE__ */ new Set();
358
+ const visit = (node) => {
359
+ if (seen.has(node)) return;
360
+ seen.add(node);
361
+ nodes.push(node);
362
+ for (const child of node.listChildren()) visit(child);
363
+ };
364
+ for (const scene of document.getRoot().listScenes()) {
365
+ for (const child of scene.listChildren()) visit(child);
366
+ }
367
+ return nodes;
368
+ }
369
+ function accessorVec3(primitive, semantic, index) {
370
+ const accessor = primitive.getAttribute(semantic);
371
+ const array = accessor?.getArray();
372
+ if (array == null) return null;
373
+ const offset = index * 3;
374
+ return [array[offset] ?? 0, array[offset + 1] ?? 0, array[offset + 2] ?? 0];
375
+ }
376
+ function accessorVec2(primitive, semantic, index) {
377
+ const accessor = primitive.getAttribute(semantic);
378
+ const array = accessor?.getArray();
379
+ if (array == null) return null;
380
+ const offset = index * 2;
381
+ return [array[offset] ?? 0, array[offset + 1] ?? 0];
382
+ }
383
+ function triangleFrom(primitive, matrix, indices) {
384
+ const localPosition0 = accessorVec3(primitive, "POSITION", indices[0]);
385
+ const localPosition1 = accessorVec3(primitive, "POSITION", indices[1]);
386
+ const localPosition2 = accessorVec3(primitive, "POSITION", indices[2]);
387
+ const uv0 = accessorVec2(primitive, "TEXCOORD_1", indices[0]);
388
+ const uv1 = accessorVec2(primitive, "TEXCOORD_1", indices[1]);
389
+ const uv2 = accessorVec2(primitive, "TEXCOORD_1", indices[2]);
390
+ if (localPosition0 === null || localPosition1 === null || localPosition2 === null || uv0 === null || uv1 === null || uv2 === null) {
391
+ throw new Error(
392
+ "TN_ASSETS_LIGHTMAP_UV2_MISSING: a baked primitive lacks positions or TEXCOORD_1."
393
+ );
394
+ }
395
+ const positions = [
396
+ transformPosition(matrix, localPosition0),
397
+ transformPosition(matrix, localPosition1),
398
+ transformPosition(matrix, localPosition2)
399
+ ];
400
+ const faceNormal = normalize(
401
+ cross(subtract(positions[1], positions[0]), subtract(positions[2], positions[0]))
402
+ );
403
+ const normals = indices.map((index) => {
404
+ const local = accessorVec3(primitive, "NORMAL", index);
405
+ return local === null ? faceNormal : transformNormal(matrix, local);
406
+ });
407
+ return {
408
+ material: primitive.getMaterial()?.getName() ?? "",
409
+ normals,
410
+ positions,
411
+ uv: [uv0, uv1, uv2]
412
+ };
413
+ }
414
+ function sceneTriangles(document, nodes) {
415
+ const meshUses = /* @__PURE__ */ new Map();
416
+ for (const node of nodes) {
417
+ const mesh = node.getMesh();
418
+ if (mesh !== null) meshUses.set(mesh, (meshUses.get(mesh) ?? 0) + 1);
419
+ }
420
+ if ([...meshUses.values()].some((count) => count > 1)) {
421
+ throw new Error(
422
+ "TN_ASSETS_LIGHTMAP_INSTANCING_UNSUPPORTED: one mesh is used by multiple scene nodes."
423
+ );
424
+ }
425
+ const triangles = [];
426
+ for (const node of nodes) {
427
+ const mesh = node.getMesh();
428
+ if (mesh === null) continue;
429
+ for (const primitive of mesh.listPrimitives()) {
430
+ const indices = primitive.getIndices()?.getArray();
431
+ if (indices == null) {
432
+ throw new Error("TN_ASSETS_LIGHTMAP_GEOMETRY_UNSUPPORTED: bake input is not indexed.");
433
+ }
434
+ for (let offset = 0; offset < indices.length; offset += 3) {
435
+ const first = indices[offset];
436
+ const second = indices[offset + 1];
437
+ const third = indices[offset + 2];
438
+ if (first === void 0 || second === void 0 || third === void 0) continue;
439
+ triangles.push(triangleFrom(primitive, node.getWorldMatrix(), [first, second, third]));
440
+ }
441
+ }
442
+ }
443
+ return triangles;
444
+ }
445
+ function sceneLights(nodes) {
446
+ const lights = [];
447
+ for (const node of nodes) {
448
+ const light = node.getExtension("KHR_lights_punctual");
449
+ if (light === null) continue;
450
+ const matrix = node.getWorldMatrix();
451
+ lights.push({
452
+ color: light.getColor(),
453
+ direction: transformDirection(matrix, [0, 0, -1]),
454
+ innerConeAngle: light.getInnerConeAngle(),
455
+ intensity: light.getIntensity(),
456
+ outerConeAngle: light.getOuterConeAngle(),
457
+ position: transformPosition(matrix, [0, 0, 0]),
458
+ range: light.getRange(),
459
+ type: light.getType()
460
+ });
461
+ }
462
+ return lights;
463
+ }
464
+ function buildOcclusionIndex(triangles) {
465
+ const geometry = new BufferGeometry();
466
+ geometry.setAttribute(
467
+ "position",
468
+ new BufferAttribute(
469
+ new Float32Array(
470
+ triangles.flatMap((triangle) => triangle.positions.flatMap((position) => [...position]))
471
+ ),
472
+ 3
473
+ )
474
+ );
475
+ const bvh = new MeshBVH(geometry);
476
+ const ray = new Ray();
477
+ return {
478
+ dispose() {
479
+ geometry.dispose();
480
+ },
481
+ hits(origin, direction, maximum) {
482
+ ray.origin.copy(new Vector3(...origin));
483
+ ray.direction.copy(new Vector3(...direction));
484
+ return bvh.raycastFirst(ray, DoubleSide, 1e-6, maximum) !== null;
485
+ }
486
+ };
487
+ }
488
+ function sampleLight(light, position) {
489
+ if (light.type === "directional") {
490
+ return {
491
+ direction: scale(light.direction, -1),
492
+ maximum: Number.POSITIVE_INFINITY,
493
+ strength: light.intensity
494
+ };
495
+ }
496
+ const offset = subtract(light.position, position);
497
+ const distance = length(offset);
498
+ if (distance === 0 || light.range !== null && distance > light.range) return null;
499
+ let strength = light.intensity / Math.max(distance * distance, 1e-6);
500
+ if (light.type === "spot") {
501
+ const fromLight = scale(normalize(offset), -1);
502
+ const cosine = dot(light.direction, fromLight);
503
+ const outer = Math.cos(light.outerConeAngle);
504
+ if (cosine <= outer) return null;
505
+ const inner = Math.cos(light.innerConeAngle);
506
+ strength *= inner === outer ? 1 : Math.min(1, (cosine - outer) / (inner - outer));
507
+ }
508
+ return { direction: scale(offset, 1 / distance), maximum: distance - 1e-5, strength };
509
+ }
510
+ function barycentric(point, triangle) {
511
+ const [a, b, c] = triangle;
512
+ const denominator = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
513
+ if (Math.abs(denominator) < 1e-12) return null;
514
+ const first = ((b[1] - c[1]) * (point[0] - c[0]) + (c[0] - b[0]) * (point[1] - c[1])) / denominator;
515
+ const second = ((c[1] - a[1]) * (point[0] - c[0]) + (a[0] - c[0]) * (point[1] - c[1])) / denominator;
516
+ const third = 1 - first - second;
517
+ return first >= -1e-7 && second >= -1e-7 && third >= -1e-7 ? [first, second, third] : null;
518
+ }
519
+ function interpolate(values, weights) {
520
+ return add(
521
+ add(scale(values[0], weights[0]), scale(values[1], weights[1])),
522
+ scale(values[2], weights[2])
523
+ );
524
+ }
525
+ function shadeTexel(position, normal, lights, occlusion) {
526
+ let color = [0, 0, 0];
527
+ let occluded = false;
528
+ const origin = add(position, scale(normal, 1e-5));
529
+ for (const light of lights) {
530
+ const sample = sampleLight(light, position);
531
+ if (sample === null) continue;
532
+ const lambert = Math.max(0, dot(normal, sample.direction));
533
+ if (lambert === 0) continue;
534
+ const blocked = occlusion.hits(origin, sample.direction, sample.maximum);
535
+ if (blocked) {
536
+ occluded = true;
537
+ continue;
538
+ }
539
+ color = add(color, scale(light.color, lambert * sample.strength));
540
+ }
541
+ return { color, occluded };
542
+ }
543
+ function rasterize(triangles, lights, occlusion, width, height) {
544
+ const pixels = new Uint8Array(width * height * 4);
545
+ const valid = new Uint8Array(width * height);
546
+ let occludedTexels = 0;
547
+ for (const triangle of triangles) {
548
+ const imageUv = triangle.uv.map(([u, v]) => [u * width, (1 - v) * height]);
549
+ const minX = Math.max(0, Math.floor(Math.min(...imageUv.map((value) => value[0]))));
550
+ const maxX = Math.min(width - 1, Math.ceil(Math.max(...imageUv.map((value) => value[0]))));
551
+ const minY = Math.max(0, Math.floor(Math.min(...imageUv.map((value) => value[1]))));
552
+ const maxY = Math.min(height - 1, Math.ceil(Math.max(...imageUv.map((value) => value[1]))));
553
+ for (let y = minY; y <= maxY; y += 1) {
554
+ for (let x = minX; x <= maxX; x += 1) {
555
+ const weights = barycentric([x + 0.5, y + 0.5], imageUv);
556
+ if (weights === null) continue;
557
+ const position = interpolate(triangle.positions, weights);
558
+ const normal = normalize(interpolate(triangle.normals, weights));
559
+ const shaded = shadeTexel(position, normal, lights, occlusion);
560
+ const pixel = y * width + x;
561
+ const offset = pixel * 4;
562
+ pixels[offset] = Math.round(Math.min(1, Math.max(0, shaded.color[0])) * 255);
563
+ pixels[offset + 1] = Math.round(Math.min(1, Math.max(0, shaded.color[1])) * 255);
564
+ pixels[offset + 2] = Math.round(Math.min(1, Math.max(0, shaded.color[2])) * 255);
565
+ pixels[offset + 3] = 255;
566
+ valid[pixel] = 1;
567
+ if (shaded.occluded) occludedTexels += 1;
568
+ }
569
+ }
570
+ }
571
+ return { occludedTexels, pixels, valid };
572
+ }
573
+ function dilate(pixels, valid, width, height, steps) {
574
+ let filled = 0;
575
+ for (let step = 0; step < steps; step += 1) {
576
+ const sourcePixels = pixels.slice();
577
+ const sourceValid = valid.slice();
578
+ for (let y = 0; y < height; y += 1) {
579
+ for (let x = 0; x < width; x += 1) {
580
+ const pixel = y * width + x;
581
+ if (sourceValid[pixel] === 1) continue;
582
+ let source = -1;
583
+ for (let yOffset = -1; yOffset <= 1 && source < 0; yOffset += 1) {
584
+ for (let xOffset = -1; xOffset <= 1; xOffset += 1) {
585
+ const neighborX = x + xOffset;
586
+ const neighborY = y + yOffset;
587
+ if (neighborX < 0 || neighborX >= width || neighborY < 0 || neighborY >= height)
588
+ continue;
589
+ const candidate = neighborY * width + neighborX;
590
+ if (sourceValid[candidate] === 1) {
591
+ source = candidate;
592
+ break;
593
+ }
594
+ }
595
+ }
596
+ if (source < 0) continue;
597
+ pixels.set(sourcePixels.subarray(source * 4, source * 4 + 4), pixel * 4);
598
+ valid[pixel] = 1;
599
+ filled += 1;
600
+ }
601
+ }
602
+ }
603
+ return filled;
604
+ }
605
+ function bakeStaticLightmap(document, width, height, padding) {
606
+ const nodes = reachableNodes(document);
607
+ const triangles = sceneTriangles(document, nodes);
608
+ const lights = sceneLights(nodes);
609
+ if (lights.length === 0) {
610
+ throw new Error(
611
+ "TN_ASSETS_LIGHTMAP_LIGHTS_MISSING: the static scene contains no punctual light."
612
+ );
613
+ }
614
+ const occlusion = buildOcclusionIndex(triangles);
615
+ const raster = rasterize(triangles, lights, occlusion, width, height);
616
+ occlusion.dispose();
617
+ const validTexels = raster.valid.reduce((sum, value) => sum + (value === 1 ? 1 : 0), 0);
618
+ const dilatedTexels = dilate(raster.pixels, raster.valid, width, height, padding);
619
+ return {
620
+ dilatedTexels,
621
+ height,
622
+ materialTargets: [...new Set(triangles.map((triangle) => triangle.material))].sort(),
623
+ occludedTexels: raster.occludedTexels,
624
+ pixels: raster.pixels,
625
+ validTexels,
626
+ width
627
+ };
628
+ }
629
+ async function decodeImageBytes(bytes, logicalPath) {
630
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) {
631
+ const { decode } = await import('jpeg-js');
632
+ const image = decode(bytes, { useTArray: true });
633
+ assertRgba(image.data.length, image.width, image.height, logicalPath);
634
+ return { data: new Uint8Array(image.data), height: image.height, width: image.width };
635
+ }
636
+ if (parsePng(bytes) !== void 0) {
637
+ const image = PNG.sync.read(bytes);
638
+ assertRgba(image.data.length, image.width, image.height, logicalPath);
639
+ return { data: new Uint8Array(image.data), height: image.height, width: image.width };
640
+ }
641
+ throw new Error(
642
+ `TN_ASSETS_TEXTURE_CONTAINER: '${logicalPath}' is not a PNG or JPEG the KTX2 encoder can read; convert it to .png or .jpg.`
643
+ );
644
+ }
645
+ function assertRgba(length2, width, height, logicalPath) {
646
+ if (width <= 0 || height <= 0 || length2 !== width * height * 4) {
647
+ throw new Error(
648
+ `TN_ASSETS_TEXTURE_UNDECODABLE: '${logicalPath}' did not decode to 8-bit RGBA (${width}x${height}, ${length2} bytes).`
649
+ );
650
+ }
651
+ }
652
+
653
+ // src/passes/glob.ts
654
+ function globMatch(glob, logicalPath) {
655
+ return new RegExp(`^${globSource(glob)}$`, "u").test(logicalPath);
656
+ }
657
+ function globSource(glob) {
658
+ let source = "";
659
+ let index = 0;
660
+ for (; ; ) {
661
+ const char = glob[index];
662
+ if (char === void 0) break;
663
+ if (char === "*") {
664
+ if (glob[index + 1] === "*") {
665
+ let end = index;
666
+ while (glob[end] === "*") end += 1;
667
+ if (glob[end] === "/") {
668
+ index = end + 1;
669
+ source += "(?:[^/]+/)*";
670
+ continue;
671
+ }
672
+ index = end;
673
+ source += ".*";
674
+ continue;
675
+ }
676
+ index += 1;
677
+ source += "[^/]*";
678
+ continue;
679
+ }
680
+ if (char === "?") {
681
+ index += 1;
682
+ source += "[^/]";
683
+ continue;
684
+ }
685
+ index += 1;
686
+ source += char.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
687
+ }
688
+ return source;
689
+ }
690
+
691
+ // src/passes/texture.ts
692
+ var TRANSCODE_TARGETS = {
693
+ etc1s: ["bc1", "etc2"],
694
+ uastc: ["astc4x4", "bc7"]
695
+ };
696
+ var DEFAULT_ETC1S_QUALITY = 150;
697
+ var BLOCK_SIZE = 4;
698
+ var NORMAL_MAP_BASENAME = /(?:^|[_-])(?:normal|nrm)$/iu;
699
+ async function encodeLinearRgbaKtx2(data, width, height) {
700
+ const encoded = await encodeToKTX2(new Uint8Array([0]), {
701
+ generateMipmap: true,
702
+ imageDecoder: async () => ({ data, height, width }),
703
+ isPerceptual: false,
704
+ isSetKTX2SRGBTransferFunc: false,
705
+ isUASTC: false,
706
+ qualityLevel: DEFAULT_ETC1S_QUALITY
707
+ });
708
+ const container = read(encoded);
709
+ if (container.levelCount < 2) {
710
+ throw new Error(
711
+ `TN_ASSETS_MIP_CHAIN_INCOMPLETE: generated lightmap encoded without a mip chain (${String(container.levelCount)} level(s)).`
712
+ );
713
+ }
714
+ return Buffer.from(encoded);
715
+ }
716
+ function texturePass(options = {}) {
717
+ return {
718
+ configuration: {
719
+ overrides: options.overrides ?? [],
720
+ quality: options.quality ?? DEFAULT_ETC1S_QUALITY
721
+ },
722
+ apply: async (input, logicalPath) => {
723
+ if (classify(logicalPath) !== "texture") return input;
724
+ const stats = textureStats(input);
725
+ if (stats.width <= 0 || stats.height <= 0) {
726
+ throw new Error(
727
+ `TN_ASSETS_TEXTURE_UNREADABLE: '${logicalPath}' has no readable PNG/JPEG header; the KTX2 pass cannot encode it.`
728
+ );
729
+ }
730
+ const decoded = await decodeImageBytes(input, logicalPath);
731
+ const choice = chooseCodec(logicalPath, rgbaHasAlpha(decoded.data), options);
732
+ if (choice.codec === "none") return input;
733
+ assertBlockAligned(logicalPath, choice.codec, decoded.width, decoded.height);
734
+ const encoded = await encodeToKTX2(
735
+ new Uint8Array(input.buffer, input.byteOffset, input.byteLength),
736
+ {
737
+ generateMipmap: true,
738
+ imageDecoder: async () => decoded,
739
+ ...encodeSettingsFor(choice)
740
+ }
741
+ );
742
+ const container = read(encoded);
743
+ if (container.levelCount < 2) {
744
+ throw new Error(
745
+ `TN_ASSETS_MIP_CHAIN_INCOMPLETE: '${logicalPath}' encoded without a mip chain (${String(container.levelCount)} level(s)).`
746
+ );
747
+ }
748
+ return {
749
+ buffer: Buffer.from(encoded),
750
+ entry: { format: choice.codec, transcodeTargets: TRANSCODE_TARGETS[choice.codec] },
751
+ outputExtension: ".ktx2"
752
+ };
753
+ },
754
+ name: "ktx2"
755
+ };
756
+ }
757
+ function assertBlockAligned(logicalPath, codec, width, height) {
758
+ if (width % BLOCK_SIZE === 0 && height % BLOCK_SIZE === 0) return;
759
+ throw new Error(
760
+ `TN_ASSETS_TEXTURE_BLOCK_SIZE: '${logicalPath}' is ${width}x${height}, which is not a multiple of the ${BLOCK_SIZE}x${BLOCK_SIZE} block the ${codec} codec transcodes to (${TRANSCODE_TARGETS[codec].join(", ")}); WebGPU rejects such a texture at draw time. Resize the source to a multiple of ${BLOCK_SIZE}, or declare a texture override with codec "none" for it.`
761
+ );
762
+ }
763
+ function rgbaHasAlpha(rgba) {
764
+ for (let offset = 3; offset < rgba.length; offset += 4) {
765
+ if ((rgba[offset] ?? 255) !== 255) return true;
766
+ }
767
+ return false;
768
+ }
769
+ function chooseCodec(logicalPath, alpha, options) {
770
+ const normalMap = NORMAL_MAP_BASENAME.test(baseNameOf(logicalPath));
771
+ const fallbackQuality = options.quality ?? DEFAULT_ETC1S_QUALITY;
772
+ for (const override of options.overrides ?? []) {
773
+ if (!globMatch(override.glob, logicalPath)) continue;
774
+ return {
775
+ codec: override.codec,
776
+ normalMap,
777
+ quality: clampQuality(override.quality ?? fallbackQuality)
778
+ };
779
+ }
780
+ return {
781
+ codec: alpha || normalMap ? "uastc" : "etc1s",
782
+ normalMap,
783
+ quality: clampQuality(fallbackQuality)
784
+ };
785
+ }
786
+ function encodeSettingsFor(choice) {
787
+ if (choice.codec === "none") return {};
788
+ if (choice.codec === "uastc") {
789
+ return {
790
+ isUASTC: true,
791
+ ...!choice.normalMap ? {} : (
792
+ // Quality is an ETC1S knob (qualityLevel 1–255); UASTC runs documented defaults.
793
+ { isPerceptual: false, isNormalMap: true, isSetKTX2SRGBTransferFunc: false }
794
+ )
795
+ };
796
+ }
797
+ return { isUASTC: false, qualityLevel: choice.quality };
798
+ }
799
+ function clampQuality(quality) {
800
+ return Math.min(255, Math.max(1, Math.round(quality)));
801
+ }
802
+ function baseNameOf(logicalPath) {
803
+ const file = logicalPath.replaceAll("\\", "/").split("/").pop() ?? logicalPath;
804
+ const dot2 = file.lastIndexOf(".");
805
+ return dot2 <= 0 ? file : file.slice(0, dot2);
806
+ }
807
+
808
+ // src/passes/lightmap.ts
809
+ function positiveInteger(value, label) {
810
+ if (!Number.isSafeInteger(value) || value <= 0) {
811
+ throw new Error(`TN_ASSETS_LIGHTMAP_CONFIG_INVALID: ${label} must be a positive integer.`);
812
+ }
813
+ return value;
814
+ }
815
+ function remapArray(source, itemSize, oldIndexes) {
816
+ const Constructor = source.constructor;
817
+ const target = new Constructor(oldIndexes.length * itemSize);
818
+ for (let targetIndex = 0; targetIndex < oldIndexes.length; targetIndex += 1) {
819
+ const sourceIndex = oldIndexes[targetIndex] ?? 0;
820
+ for (let component = 0; component < itemSize; component += 1) {
821
+ target[targetIndex * itemSize + component] = source[sourceIndex * itemSize + component] ?? 0;
822
+ }
823
+ }
824
+ return target;
825
+ }
826
+ function replaceAccessor(document, buffer, source, array) {
827
+ return document.createAccessor(source.getName()).setType(source.getType()).setNormalized(source.getNormalized()).setArray(array).setBuffer(buffer);
828
+ }
829
+ async function readDocument(input, logicalPath) {
830
+ try {
831
+ return await new NodeIO().registerExtensions(ALL_EXTENSIONS).readBinary(input);
832
+ } catch (error) {
833
+ const detail = error instanceof Error ? error.message : String(error);
834
+ throw new Error(
835
+ `TN_ASSETS_LIGHTMAP_UNREADABLE: could not parse '${logicalPath}' as a self-contained GLB: ${detail}`
836
+ );
837
+ }
838
+ }
839
+ async function createAtlasApi() {
840
+ const XAtlas = Api(createXAtlasModule);
841
+ const wasmPath = createRequire(import.meta.url).resolve("xatlasjs/dist/node/xatlas.wasm");
842
+ let loaded = () => {
843
+ };
844
+ const ready = new Promise((resolve) => {
845
+ loaded = resolve;
846
+ });
847
+ const api = new XAtlas(loaded, () => wasmPath);
848
+ await ready;
849
+ return api;
850
+ }
851
+ function primitivesOf(document) {
852
+ const rows = [];
853
+ for (const [meshIndex, mesh] of document.getRoot().listMeshes().entries()) {
854
+ for (const [primitiveIndex, primitive] of mesh.listPrimitives().entries()) {
855
+ rows.push({ key: `${String(meshIndex)}:${String(primitiveIndex)}`, primitive });
856
+ }
857
+ }
858
+ return rows;
859
+ }
860
+ function chartCount(atlas) {
861
+ let charts = 0;
862
+ for (const mesh of atlas.meshes) {
863
+ const parent = /* @__PURE__ */ new Map();
864
+ const rootOf = (value) => {
865
+ const parentValue = parent.get(value) ?? value;
866
+ if (parentValue === value) return value;
867
+ const root = rootOf(parentValue);
868
+ parent.set(value, root);
869
+ return root;
870
+ };
871
+ const join = (left, right) => {
872
+ const leftRoot = rootOf(left);
873
+ const rightRoot = rootOf(right);
874
+ parent.set(leftRoot, rightRoot);
875
+ };
876
+ for (let offset = 0; offset < mesh.index.length; offset += 3) {
877
+ const first = mesh.index[offset];
878
+ const second = mesh.index[offset + 1];
879
+ const third = mesh.index[offset + 2];
880
+ if (first === void 0 || second === void 0 || third === void 0) continue;
881
+ parent.set(first, parent.get(first) ?? first);
882
+ parent.set(second, parent.get(second) ?? second);
883
+ parent.set(third, parent.get(third) ?? third);
884
+ join(first, second);
885
+ join(second, third);
886
+ }
887
+ charts += new Set([...parent.keys()].map(rootOf)).size;
888
+ }
889
+ return charts;
890
+ }
891
+ function assertStatic(document, logicalPath) {
892
+ const hasMorphTargets = document.getRoot().listMeshes().some((mesh) => mesh.listPrimitives().some((primitive) => primitive.listTargets().length > 0));
893
+ if (document.getRoot().listAnimations().length > 0 || document.getRoot().listSkins().length > 0 || hasMorphTargets) {
894
+ throw new Error(
895
+ `TN_ASSETS_LIGHTMAP_UNSUPPORTED_ANIMATION: '${logicalPath}' contains animated, skinned, or morphed content.`
896
+ );
897
+ }
898
+ }
899
+ function addPrimitive(api, row, logicalPath) {
900
+ const positions = row.primitive.getAttribute("POSITION")?.getArray();
901
+ if (!(positions instanceof Float32Array)) {
902
+ throw new Error(
903
+ `TN_ASSETS_LIGHTMAP_GEOMETRY_UNSUPPORTED: '${logicalPath}' needs Float32 positions.`
904
+ );
905
+ }
906
+ const sourceIndices = row.primitive.getIndices()?.getArray();
907
+ const vertexCount = positions.length / 3;
908
+ if (vertexCount > 65535) {
909
+ throw new Error(
910
+ `TN_ASSETS_LIGHTMAP_GEOMETRY_UNSUPPORTED: '${logicalPath}' exceeds xatlasjs's 65,535-vertex Uint16 limit.`
911
+ );
912
+ }
913
+ const indices = sourceIndices == null ? Uint16Array.from({ length: vertexCount }, (_, index) => index) : sourceIndices instanceof Uint16Array ? sourceIndices : Uint16Array.from(sourceIndices);
914
+ const normals = row.primitive.getAttribute("NORMAL")?.getArray();
915
+ const uv0 = row.primitive.getAttribute("TEXCOORD_0")?.getArray();
916
+ api.addMesh(
917
+ indices,
918
+ positions,
919
+ normals instanceof Float32Array ? normals : null,
920
+ uv0 instanceof Float32Array ? uv0 : null,
921
+ row.key,
922
+ normals instanceof Float32Array,
923
+ false
924
+ );
925
+ }
926
+ function remapAttributes(document, buffer, row, result) {
927
+ for (const semantic of row.primitive.listSemantics()) {
928
+ const source = row.primitive.getAttribute(semantic);
929
+ const sourceArray = source?.getArray();
930
+ if (source === null || sourceArray === null) continue;
931
+ const remapped = semantic === "POSITION" ? result.vertex.vertices : remapArray(sourceArray, source.getElementSize(), result.oldIndexes);
932
+ row.primitive.setAttribute(semantic, replaceAccessor(document, buffer, source, remapped));
933
+ }
934
+ }
935
+ function writeAtlas(document, rows, atlas) {
936
+ const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer("lightmap");
937
+ const rowsByKey = new Map(rows.map((row) => [row.key, row]));
938
+ for (const result of atlas.meshes) {
939
+ const row = rowsByKey.get(result.mesh);
940
+ if (row === void 0) {
941
+ throw new Error(`TN_ASSETS_LIGHTMAP_ATLAS_INVALID: missing primitive '${result.mesh}'.`);
942
+ }
943
+ remapAttributes(document, buffer, row, result);
944
+ row.primitive.setAttribute(
945
+ "TEXCOORD_1",
946
+ document.createAccessor("lightmap-uv").setType("VEC2").setArray(result.vertex.coords1).setBuffer(buffer)
947
+ );
948
+ row.primitive.setIndices(
949
+ document.createAccessor("lightmap-indices").setType("SCALAR").setArray(result.index).setBuffer(buffer)
950
+ );
951
+ }
952
+ }
953
+ async function bakeUv2(document, rows, atlasSize, padding, logicalPath) {
954
+ const startedAt = performance.now();
955
+ const api = await createAtlasApi();
956
+ api.createAtlas();
957
+ try {
958
+ for (const row of rows) addPrimitive(api, row, logicalPath);
959
+ const atlas = api.generateAtlas(
960
+ {},
961
+ { blockAlign: true, padding, resolution: atlasSize, texelsPerUnit: 0 }
962
+ );
963
+ writeAtlas(document, rows, atlas);
964
+ const bake = bakeStaticLightmap(document, atlas.width, atlas.height, padding);
965
+ const lightmap = await encodeLinearRgbaKtx2(bake.pixels, bake.width, bake.height);
966
+ return {
967
+ auxiliaryOutputs: [
968
+ {
969
+ buffer: lightmap,
970
+ extension: ".ktx2",
971
+ manifestField: "lightmaps",
972
+ metadata: {
973
+ bytesBefore: bake.pixels.byteLength,
974
+ dilatedTexels: bake.dilatedTexels,
975
+ format: "etc1s",
976
+ materialTargets: bake.materialTargets,
977
+ occludedTexels: bake.occludedTexels,
978
+ texCoord: 1,
979
+ validTexels: bake.validTexels
980
+ },
981
+ role: "lightmap"
982
+ }
983
+ ],
984
+ buffer: Buffer.from(
985
+ await new NodeIO().registerExtensions(ALL_EXTENSIONS).writeBinary(document)
986
+ ),
987
+ entry: {
988
+ lightmapBakeMs: performance.now() - startedAt,
989
+ lightmapAtlas: {
990
+ atlasCount: atlas.atlasCount,
991
+ chartCount: chartCount(atlas),
992
+ height: atlas.height,
993
+ padding,
994
+ skippedMeshes: [],
995
+ texelsPerUnit: atlas.texelsPerUnit,
996
+ width: atlas.width
997
+ }
998
+ }
999
+ };
1000
+ } finally {
1001
+ api.destroyAtlas();
1002
+ }
1003
+ }
1004
+ function lightmapPass(options) {
1005
+ const atlasSize = positiveInteger(options.atlasSize, "atlasSize");
1006
+ const padding = positiveInteger(options.padding, "padding");
1007
+ return {
1008
+ cacheKey: JSON.stringify({ atlasSize, padding }),
1009
+ name: "lightmap-uv2",
1010
+ async apply(input, logicalPath) {
1011
+ if (!/\.glb$/iu.test(logicalPath)) return input;
1012
+ const document = await readDocument(input, logicalPath);
1013
+ assertStatic(document, logicalPath);
1014
+ const rows = primitivesOf(document);
1015
+ if (rows.length === 0) return input;
1016
+ return bakeUv2(document, rows, atlasSize, padding, logicalPath);
1017
+ }
1018
+ };
1019
+ }
1020
+
1021
+ // src/virtual/partition.ts
1022
+ function edgeKey(a, b) {
1023
+ return a < b ? a * 4294967296 + b : b * 4294967296 + a;
1024
+ }
1025
+ function clusterBoundaryEdges(indices, start, count) {
1026
+ const seen = /* @__PURE__ */ new Map();
1027
+ for (let offset = start; offset < start + count; offset += 3) {
1028
+ for (let corner = 0; corner < 3; corner += 1) {
1029
+ const key = edgeKey(
1030
+ indices[offset + corner],
1031
+ indices[offset + (corner + 1) % 3]
1032
+ );
1033
+ seen.set(key, (seen.get(key) ?? 0) + 1);
1034
+ }
1035
+ }
1036
+ const boundary = [];
1037
+ for (const [key, uses] of seen) if (uses === 1) boundary.push(key);
1038
+ return boundary;
1039
+ }
1040
+ function clusterAdjacency(indices, ranges) {
1041
+ const owners = /* @__PURE__ */ new Map();
1042
+ const boundaries = [];
1043
+ for (let cluster = 0; cluster < ranges.length; cluster += 1) {
1044
+ const range = ranges[cluster];
1045
+ const boundary = clusterBoundaryEdges(indices, range.start, range.count);
1046
+ boundaries.push(boundary);
1047
+ for (const key of boundary) {
1048
+ const list = owners.get(key);
1049
+ if (list === void 0) owners.set(key, [cluster]);
1050
+ else list.push(cluster);
1051
+ }
1052
+ }
1053
+ const neighbours = ranges.map(() => []);
1054
+ const weights = ranges.map(() => []);
1055
+ const index = ranges.map(() => /* @__PURE__ */ new Map());
1056
+ for (let cluster = 0; cluster < boundaries.length; cluster += 1) {
1057
+ for (const key of boundaries[cluster]) {
1058
+ for (const other of owners.get(key) ?? []) {
1059
+ if (other === cluster) continue;
1060
+ const slot = index[cluster].get(other);
1061
+ if (slot === void 0) {
1062
+ index[cluster].set(
1063
+ other,
1064
+ neighbours[cluster].length
1065
+ );
1066
+ neighbours[cluster].push(other);
1067
+ weights[cluster].push(1);
1068
+ } else {
1069
+ const list = weights[cluster];
1070
+ list[slot] = list[slot] + 1;
1071
+ }
1072
+ }
1073
+ }
1074
+ }
1075
+ return { neighbours, weights };
1076
+ }
1077
+ function partitionClusters(adjacency, options) {
1078
+ const clusterCount = adjacency.neighbours.length;
1079
+ if (!Number.isInteger(options.targetSize) || options.targetSize < 2)
1080
+ throw new Error("partitionClusters targetSize must be an integer of at least two.");
1081
+ if (options.centres.length !== clusterCount * 3)
1082
+ throw new Error(
1083
+ `partitionClusters needs one centre per cluster: ${options.centres.length / 3} centres for ${clusterCount} clusters.`
1084
+ );
1085
+ const groupOf = new Int32Array(clusterCount).fill(-1);
1086
+ const groups = [];
1087
+ for (let seed = 0; seed < clusterCount; seed += 1) {
1088
+ if (groupOf[seed] !== -1) continue;
1089
+ const group = [seed];
1090
+ groupOf[seed] = groups.length;
1091
+ const candidates = /* @__PURE__ */ new Map();
1092
+ const offer = (member) => {
1093
+ const neighbours = adjacency.neighbours[member];
1094
+ const weights = adjacency.weights[member];
1095
+ for (let slot = 0; slot < neighbours.length; slot += 1) {
1096
+ const other = neighbours[slot];
1097
+ if (groupOf[other] !== -1) continue;
1098
+ candidates.set(other, (candidates.get(other) ?? 0) + weights[slot]);
1099
+ }
1100
+ };
1101
+ offer(seed);
1102
+ while (group.length < options.targetSize && candidates.size > 0) {
1103
+ let best = -1;
1104
+ let bestWeight = -1;
1105
+ for (const [candidate, weight] of candidates) {
1106
+ if (weight > bestWeight || weight === bestWeight && candidate < best) {
1107
+ best = candidate;
1108
+ bestWeight = weight;
1109
+ }
1110
+ }
1111
+ candidates.delete(best);
1112
+ if (groupOf[best] !== -1) continue;
1113
+ groupOf[best] = groups.length;
1114
+ group.push(best);
1115
+ offer(best);
1116
+ }
1117
+ groups.push(group);
1118
+ }
1119
+ return mergeUndersizedGroups(groups, groupOf, options);
1120
+ }
1121
+ function mergeUndersizedGroups(groups, groupOf, options) {
1122
+ if (groups.length < 2) return groups;
1123
+ const half = Math.max(1, Math.floor(options.targetSize / 2));
1124
+ const centre = (group) => {
1125
+ let x = 0;
1126
+ let y = 0;
1127
+ let z = 0;
1128
+ for (const cluster of group) {
1129
+ x += options.centres[cluster * 3];
1130
+ y += options.centres[cluster * 3 + 1];
1131
+ z += options.centres[cluster * 3 + 2];
1132
+ }
1133
+ return [x / group.length, y / group.length, z / group.length];
1134
+ };
1135
+ const centres = groups.map(centre);
1136
+ const merged = groups.map((group) => [...group]);
1137
+ for (let index = 0; index < merged.length; index += 1) {
1138
+ const group = merged[index];
1139
+ if (group === void 0 || group.length >= half) continue;
1140
+ let best = -1;
1141
+ let bestDistance = Number.POSITIVE_INFINITY;
1142
+ for (let other = 0; other < merged.length; other += 1) {
1143
+ const candidate = merged[other];
1144
+ if (other === index || candidate === void 0) continue;
1145
+ if (candidate.length + group.length > options.targetSize * 2) continue;
1146
+ const [ax, ay, az] = centres[index];
1147
+ const [bx, by, bz] = centres[other];
1148
+ const distance = (ax - bx) ** 2 + (ay - by) ** 2 + (az - bz) ** 2;
1149
+ if (distance < bestDistance || distance === bestDistance && other < best) {
1150
+ best = other;
1151
+ bestDistance = distance;
1152
+ }
1153
+ }
1154
+ if (best === -1) continue;
1155
+ merged[best].push(...group);
1156
+ merged[index] = void 0;
1157
+ }
1158
+ const result = merged.filter((group) => group !== void 0);
1159
+ for (let index = 0; index < result.length; index += 1)
1160
+ for (const cluster of result[index]) groupOf[cluster] = index;
1161
+ for (const group of result) group.sort((a, b) => a - b);
1162
+ return result;
1163
+ }
1164
+
1165
+ // src/virtual/dag.ts
1166
+ var DEFAULT_CONE_WEIGHT = 0;
1167
+ var DEFAULT_GROUP_SIZE = 4;
1168
+ var DEFAULT_MAX_LEVELS = 32;
1169
+ var DEFAULT_MAX_TRIANGLES = 128;
1170
+ var DEFAULT_MAX_VERTICES = 128;
1171
+ var DEFAULT_MIN_TRIANGLES = 96;
1172
+ var DEFAULT_SIMPLIFY_RATIO = 0.5;
1173
+ var UNBOUNDED_ERROR = 1e9;
1174
+ var MIN_LEVEL_REDUCTION = 0.02;
1175
+ var Uint32Sink = class {
1176
+ data;
1177
+ used = 0;
1178
+ constructor(capacity) {
1179
+ this.data = new Uint32Array(Math.max(capacity, 3));
1180
+ }
1181
+ get length() {
1182
+ return this.used;
1183
+ }
1184
+ push(value) {
1185
+ if (this.used === this.data.length) {
1186
+ const grown = new Uint32Array(this.data.length * 2);
1187
+ grown.set(this.data);
1188
+ this.data = grown;
1189
+ }
1190
+ this.data[this.used] = value;
1191
+ this.used += 1;
1192
+ }
1193
+ /** A view over what has been written. Valid until the next `push` that grows the buffer. */
1194
+ view() {
1195
+ return this.data.subarray(0, this.used);
1196
+ }
1197
+ toArray() {
1198
+ return this.data.slice(0, this.used);
1199
+ }
1200
+ };
1201
+ function unite(a, b) {
1202
+ if (b.radius <= 0) return a;
1203
+ if (a.radius <= 0) return b;
1204
+ const dx = b.x - a.x;
1205
+ const dy = b.y - a.y;
1206
+ const dz = b.z - a.z;
1207
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
1208
+ if (distance + b.radius <= a.radius) return a;
1209
+ if (distance + a.radius <= b.radius) return b;
1210
+ const radius = (a.radius + b.radius + distance) / 2;
1211
+ const step = distance === 0 ? 0 : (radius - a.radius) / distance;
1212
+ return { radius, x: a.x + dx * step, y: a.y + dy * step, z: a.z + dz * step };
1213
+ }
1214
+ function assertMesh(indices, positions) {
1215
+ if (indices.length === 0 || indices.length % 3 !== 0)
1216
+ throw new Error(
1217
+ `buildClusterDag needs a non-empty triangle list: got ${indices.length} indices.`
1218
+ );
1219
+ if (positions.length === 0 || positions.length % 3 !== 0)
1220
+ throw new Error(
1221
+ `buildClusterDag needs tightly packed vec3 positions: got ${positions.length} floats.`
1222
+ );
1223
+ const vertexCount = positions.length / 3;
1224
+ for (let slot = 0; slot < indices.length; slot += 1)
1225
+ if (indices[slot] >= vertexCount)
1226
+ throw new Error(
1227
+ `buildClusterDag index ${indices[slot]} at ${slot} is outside the ${vertexCount}-vertex buffer.`
1228
+ );
1229
+ }
1230
+ function resolveOptions(options) {
1231
+ const simplifyRatio = options.simplifyRatio ?? DEFAULT_SIMPLIFY_RATIO;
1232
+ if (simplifyRatio <= 0 || simplifyRatio >= 1)
1233
+ throw new Error(`buildClusterDag simplifyRatio must be inside (0, 1): got ${simplifyRatio}.`);
1234
+ return {
1235
+ coneWeight: options.coneWeight ?? DEFAULT_CONE_WEIGHT,
1236
+ groupSize: options.groupSize ?? DEFAULT_GROUP_SIZE,
1237
+ // LockBorder holds the group's rim still — meshoptimizer computes that rim on the welded
1238
+ // topology, which is why split seam vertices do not leak through it. ErrorAbsolute puts the
1239
+ // reported error in the mesh's own units so levels are comparable. Sparse keeps the simplifier's
1240
+ // cost proportional to the group rather than to the whole vertex buffer.
1241
+ lockFlags: options.unlockGroupBoundary ? ["ErrorAbsolute", "Sparse"] : ["LockBorder", "ErrorAbsolute", "Sparse"],
1242
+ maxLevels: options.maxLevels ?? DEFAULT_MAX_LEVELS,
1243
+ maxTriangles: options.maxTriangles ?? DEFAULT_MAX_TRIANGLES,
1244
+ maxVertices: options.maxVertices ?? DEFAULT_MAX_VERTICES,
1245
+ minTriangles: options.minTriangles ?? DEFAULT_MIN_TRIANGLES,
1246
+ simplifyRatio
1247
+ };
1248
+ }
1249
+ var ClusterDagBuilder = class {
1250
+ constructor(positions, sourceIndexCount, options) {
1251
+ this.positions = positions;
1252
+ this.settings = resolveOptions(options);
1253
+ this.sink = new Uint32Sink(sourceIndexCount * 2);
1254
+ this.weld = MeshoptSimplifier.generatePositionRemap(positions, 3);
1255
+ }
1256
+ positions;
1257
+ clusters = [];
1258
+ groups = [];
1259
+ levels = [];
1260
+ settings;
1261
+ sink;
1262
+ /** Vertices that share a position are one vertex for adjacency: a UV seam splits the vertex
1263
+ * buffer but not the surface, and two clusters that meet along one do touch. */
1264
+ weld;
1265
+ build(indices) {
1266
+ let current = this.emit(this.clusterize(indices), 0, 0, null);
1267
+ this.levels.push({
1268
+ clusterCount: current.length,
1269
+ promoted: 0,
1270
+ triangleCount: this.triangles(current)
1271
+ });
1272
+ let stopReason = current.length <= 1 ? "root" : "cap";
1273
+ for (let level = 0; current.length > 1 && level < this.settings.maxLevels; level += 1) {
1274
+ const previous = this.triangles(current);
1275
+ const fold = this.foldLevel(current, level);
1276
+ const triangleCount = this.triangles(fold.next);
1277
+ this.levels.push({
1278
+ clusterCount: fold.next.length,
1279
+ promoted: fold.promoted,
1280
+ triangleCount
1281
+ });
1282
+ current = fold.next;
1283
+ if (current.length <= 1) {
1284
+ stopReason = "root";
1285
+ break;
1286
+ }
1287
+ if (!fold.simplifiedAny || triangleCount > previous * (1 - MIN_LEVEL_REDUCTION)) {
1288
+ stopReason = "stalled";
1289
+ break;
1290
+ }
1291
+ }
1292
+ return {
1293
+ clusters: this.clusters.map((cluster) => ({ ...cluster })),
1294
+ groups: this.groups,
1295
+ indices: this.sink.toArray(),
1296
+ levels: this.levels,
1297
+ roots: current.slice(),
1298
+ stopReason
1299
+ };
1300
+ }
1301
+ triangles(ids) {
1302
+ let total = 0;
1303
+ for (const id of ids) total += this.cluster(id).count / 3;
1304
+ return total;
1305
+ }
1306
+ cluster(id) {
1307
+ return this.clusters[id];
1308
+ }
1309
+ clusterize(source) {
1310
+ return MeshoptClusterizer.buildMeshletsFlex(
1311
+ source,
1312
+ this.positions,
1313
+ 3,
1314
+ this.settings.maxVertices,
1315
+ this.settings.minTriangles,
1316
+ this.settings.maxTriangles,
1317
+ this.settings.coneWeight
1318
+ );
1319
+ }
1320
+ emit(buffers, level, error, sourceSphere) {
1321
+ const bounds = MeshoptClusterizer.computeMeshletBounds(buffers, this.positions, 3);
1322
+ const emitted = [];
1323
+ for (let meshlet = 0; meshlet < buffers.meshletCount; meshlet += 1) {
1324
+ const extracted = MeshoptClusterizer.extractMeshlet(buffers, meshlet);
1325
+ const start = this.sink.length;
1326
+ for (let corner = 0; corner < extracted.triangles.length; corner += 1)
1327
+ this.sink.push(extracted.vertices[extracted.triangles[corner]]);
1328
+ const bound = bounds[meshlet];
1329
+ const own = {
1330
+ radius: bound.radius,
1331
+ x: bound.centerX,
1332
+ y: bound.centerY,
1333
+ z: bound.centerZ
1334
+ };
1335
+ emitted.push(this.clusters.length);
1336
+ this.clusters.push({
1337
+ bounds: {
1338
+ centerX: bound.centerX,
1339
+ centerY: bound.centerY,
1340
+ centerZ: bound.centerZ,
1341
+ coneAxisX: bound.coneAxisX,
1342
+ coneAxisY: bound.coneAxisY,
1343
+ coneAxisZ: bound.coneAxisZ,
1344
+ coneCutoff: bound.coneCutoff,
1345
+ radius: bound.radius
1346
+ },
1347
+ count: extracted.triangles.length,
1348
+ error,
1349
+ group: -1,
1350
+ level,
1351
+ parentError: Number.POSITIVE_INFINITY,
1352
+ parentSphere: own,
1353
+ sourceSphere: sourceSphere ?? own,
1354
+ start
1355
+ });
1356
+ }
1357
+ return emitted;
1358
+ }
1359
+ /** The level's clusters as the partitioner wants them: welded index space, ranges, centres. */
1360
+ partition(current) {
1361
+ const view = this.sink.view();
1362
+ const welded = new Uint32Array(view.length);
1363
+ for (let slot = 0; slot < view.length; slot += 1)
1364
+ welded[slot] = this.weld[view[slot]];
1365
+ const ranges = current.map((id) => ({
1366
+ count: this.cluster(id).count,
1367
+ start: this.cluster(id).start
1368
+ }));
1369
+ const centres = new Float32Array(current.length * 3);
1370
+ for (let slot = 0; slot < current.length; slot += 1) {
1371
+ const bounds = this.cluster(current[slot]).bounds;
1372
+ centres[slot * 3] = bounds.centerX;
1373
+ centres[slot * 3 + 1] = bounds.centerY;
1374
+ centres[slot * 3 + 2] = bounds.centerZ;
1375
+ }
1376
+ return partitionClusters(clusterAdjacency(welded, ranges), {
1377
+ centres,
1378
+ targetSize: this.settings.groupSize
1379
+ });
1380
+ }
1381
+ gather(members) {
1382
+ const view = this.sink.view();
1383
+ let size = 0;
1384
+ for (const member of members) size += this.cluster(member).count;
1385
+ const group = new Uint32Array(size);
1386
+ let cursor = 0;
1387
+ for (const member of members) {
1388
+ const cluster = this.cluster(member);
1389
+ group.set(view.subarray(cluster.start, cluster.start + cluster.count), cursor);
1390
+ cursor += cluster.count;
1391
+ }
1392
+ return group;
1393
+ }
1394
+ /**
1395
+ * Simplifies one group and emits its parents, or reports that it would not shrink.
1396
+ *
1397
+ * A group that refuses to simplify hands its members back unchanged. They carry their own geometry
1398
+ * upward and keep `parentError` at infinity until a level that *can* simplify them groups them —
1399
+ * dropping them here is what would turn a stalled group into a hole at high thresholds.
1400
+ */
1401
+ foldGroup(members, level) {
1402
+ const groupIndices = this.gather(members);
1403
+ const target = Math.max(
1404
+ 3,
1405
+ Math.floor(groupIndices.length / 3 * this.settings.simplifyRatio) * 3
1406
+ );
1407
+ const [simplified, simplifyError] = MeshoptSimplifier.simplify(
1408
+ groupIndices,
1409
+ this.positions,
1410
+ 3,
1411
+ target,
1412
+ UNBOUNDED_ERROR,
1413
+ this.settings.lockFlags
1414
+ );
1415
+ if (simplified.length >= groupIndices.length) return null;
1416
+ let error = simplifyError;
1417
+ let sphere = { radius: 0, x: 0, y: 0, z: 0 };
1418
+ for (const member of members) {
1419
+ const cluster = this.cluster(member);
1420
+ error = Math.max(error, cluster.error);
1421
+ sphere = unite(unite(sphere, cluster.sourceSphere), {
1422
+ radius: cluster.bounds.radius,
1423
+ x: cluster.bounds.centerX,
1424
+ y: cluster.bounds.centerY,
1425
+ z: cluster.bounds.centerZ
1426
+ });
1427
+ }
1428
+ const groupId = this.groups.length;
1429
+ for (const member of members) {
1430
+ const cluster = this.cluster(member);
1431
+ cluster.group = groupId;
1432
+ cluster.parentError = error;
1433
+ cluster.parentSphere = sphere;
1434
+ }
1435
+ const parents = this.emit(this.clusterize(simplified), level + 1, error, sphere);
1436
+ this.groups.push({ children: [...members], error, level, parents, simplifyError, sphere });
1437
+ return parents;
1438
+ }
1439
+ foldLevel(current, level) {
1440
+ const next = [];
1441
+ let promoted = 0;
1442
+ let simplifiedAny = false;
1443
+ for (const local of this.partition(current)) {
1444
+ const members = local.map((slot) => current[slot]);
1445
+ const parents = this.foldGroup(members, level);
1446
+ if (parents === null) {
1447
+ next.push(...members);
1448
+ promoted += members.length;
1449
+ continue;
1450
+ }
1451
+ simplifiedAny = true;
1452
+ next.push(...parents);
1453
+ }
1454
+ return { next, promoted, simplifiedAny };
1455
+ }
1456
+ };
1457
+ async function buildClusterDag(indices, positions, options = {}) {
1458
+ assertMesh(indices, positions);
1459
+ await MeshoptClusterizer.ready;
1460
+ await MeshoptSimplifier.ready;
1461
+ return new ClusterDagBuilder(positions, indices.length, options).build(indices);
1462
+ }
1463
+ var TN_VIRTUAL_GEOMETRY = "TN_virtual_geometry";
1464
+ var ROOT_PARENT_ERROR = 34028234663852886e22;
1465
+ var NO_GROUP = 4294967295;
1466
+ var VIRTUAL_ACCESSOR = "TN_VIRTUAL_GEOMETRY";
1467
+ var VirtualGeometry = class extends ExtensionProperty {
1468
+ static EXTENSION_NAME = TN_VIRTUAL_GEOMETRY;
1469
+ init() {
1470
+ this.extensionName = TN_VIRTUAL_GEOMETRY;
1471
+ this.propertyType = "VirtualGeometry";
1472
+ this.parentTypes = [PropertyType.PRIMITIVE];
1473
+ }
1474
+ getDefaults() {
1475
+ return Object.assign(super.getDefaults(), {
1476
+ clusterBounds: null,
1477
+ clusterCones: null,
1478
+ clusterErrors: null,
1479
+ clusterGroups: null,
1480
+ clusterParentSpheres: null,
1481
+ clusterRanges: null,
1482
+ clusterSourceSpheres: null,
1483
+ indices: null,
1484
+ levelTriangles: [],
1485
+ stopReason: "root"
1486
+ });
1487
+ }
1488
+ /** Cluster-ordered triangles for every level. */
1489
+ getIndices() {
1490
+ return this.getRef("indices");
1491
+ }
1492
+ setIndices(accessor) {
1493
+ return this.setRef("indices", accessor, { usage: VIRTUAL_ACCESSOR });
1494
+ }
1495
+ /** Per cluster: start and index count into `indices`. */
1496
+ getClusterRanges() {
1497
+ return this.getRef("clusterRanges");
1498
+ }
1499
+ setClusterRanges(accessor) {
1500
+ return this.setRef("clusterRanges", accessor, { usage: VIRTUAL_ACCESSOR });
1501
+ }
1502
+ /** Per cluster: its own object-space error, and its parent group's. */
1503
+ getClusterErrors() {
1504
+ return this.getRef("clusterErrors");
1505
+ }
1506
+ setClusterErrors(accessor) {
1507
+ return this.setRef("clusterErrors", accessor, { usage: VIRTUAL_ACCESSOR });
1508
+ }
1509
+ /** Per cluster: bounding sphere, centre and radius. */
1510
+ getClusterBounds() {
1511
+ return this.getRef("clusterBounds");
1512
+ }
1513
+ setClusterBounds(accessor) {
1514
+ return this.setRef("clusterBounds", accessor, { usage: VIRTUAL_ACCESSOR });
1515
+ }
1516
+ /** Per cluster: normal cone axis and cutoff. */
1517
+ getClusterCones() {
1518
+ return this.getRef("clusterCones");
1519
+ }
1520
+ setClusterCones(accessor) {
1521
+ return this.setRef("clusterCones", accessor, { usage: VIRTUAL_ACCESSOR });
1522
+ }
1523
+ /** Per cluster: the group it folds into, and the group that produced it. `NO_GROUP` for neither. */
1524
+ getClusterGroups() {
1525
+ return this.getRef("clusterGroups");
1526
+ }
1527
+ setClusterGroups(accessor) {
1528
+ return this.setRef("clusterGroups", accessor, { usage: VIRTUAL_ACCESSOR });
1529
+ }
1530
+ /**
1531
+ * Per cluster: the sphere its own error is projected through at run time — the sphere of the
1532
+ * group that produced it.
1533
+ */
1534
+ getClusterSourceSpheres() {
1535
+ return this.getRef("clusterSourceSpheres");
1536
+ }
1537
+ setClusterSourceSpheres(accessor) {
1538
+ return this.setRef("clusterSourceSpheres", accessor, { usage: VIRTUAL_ACCESSOR });
1539
+ }
1540
+ /**
1541
+ * Per cluster: the sphere its parent error is projected through — the sphere of the group it
1542
+ * folds into, shared with every sibling so they flip together.
1543
+ *
1544
+ * Two spheres rather than one because a screen-space cut cracks without them: a group's sphere
1545
+ * encloses every child's, so the parent's projected error can never fall below a child's however
1546
+ * the camera moves.
1547
+ */
1548
+ getClusterParentSpheres() {
1549
+ return this.getRef("clusterParentSpheres");
1550
+ }
1551
+ setClusterParentSpheres(accessor) {
1552
+ return this.setRef("clusterParentSpheres", accessor, { usage: VIRTUAL_ACCESSOR });
1553
+ }
1554
+ getLevelTriangles() {
1555
+ return this.get("levelTriangles");
1556
+ }
1557
+ setLevelTriangles(levels) {
1558
+ return this.set("levelTriangles", levels);
1559
+ }
1560
+ getStopReason() {
1561
+ return this.get("stopReason");
1562
+ }
1563
+ setStopReason(reason) {
1564
+ return this.set("stopReason", reason);
1565
+ }
1566
+ };
1567
+ var TNVirtualGeometry = class extends Extension {
1568
+ static EXTENSION_NAME = TN_VIRTUAL_GEOMETRY;
1569
+ extensionName = TN_VIRTUAL_GEOMETRY;
1570
+ prewriteTypes = [PropertyType.ACCESSOR];
1571
+ createVirtualGeometry() {
1572
+ return new VirtualGeometry(this.document.getGraph());
1573
+ }
1574
+ read(context) {
1575
+ const meshDefs = context.jsonDoc.json.meshes ?? [];
1576
+ meshDefs.forEach((meshDef, meshIndex) => {
1577
+ (meshDef.primitives ?? []).forEach((primitiveDef, primitiveIndex) => {
1578
+ const def = primitiveDef.extensions?.[TN_VIRTUAL_GEOMETRY];
1579
+ if (def === void 0) return;
1580
+ const virtual = this.createVirtualGeometry().setIndices(context.accessors[def.indices]).setClusterRanges(context.accessors[def.clusterRanges]).setClusterErrors(context.accessors[def.clusterErrors]).setClusterBounds(context.accessors[def.clusterBounds]).setClusterCones(context.accessors[def.clusterCones]).setClusterGroups(context.accessors[def.clusterGroups]).setClusterSourceSpheres(context.accessors[def.clusterSourceSpheres]).setClusterParentSpheres(context.accessors[def.clusterParentSpheres]).setLevelTriangles([...def.levelTriangles]).setStopReason(def.stopReason);
1581
+ const mesh = context.meshes[meshIndex];
1582
+ const primitive = mesh?.listPrimitives()[primitiveIndex];
1583
+ primitive?.setExtension(TN_VIRTUAL_GEOMETRY, virtual);
1584
+ });
1585
+ });
1586
+ return this;
1587
+ }
1588
+ /**
1589
+ * These accessors belong to no primitive, so the writer would otherwise have no usage for them
1590
+ * and no reason to lay them out. Grouping them by parent keeps one primitive's DAG in one buffer
1591
+ * view, which is what PRD-285's streaming will want to fetch a range of.
1592
+ */
1593
+ prewrite(context) {
1594
+ context.accessorUsageGroupedByParent.add(VIRTUAL_ACCESSOR);
1595
+ for (const property of this.properties) {
1596
+ for (const accessor of accessorsOf(property))
1597
+ context.addAccessorToUsageGroup(accessor, VIRTUAL_ACCESSOR);
1598
+ }
1599
+ return this;
1600
+ }
1601
+ write(context) {
1602
+ for (const mesh of this.document.getRoot().listMeshes()) {
1603
+ for (const primitive of mesh.listPrimitives()) {
1604
+ const virtual = primitive.getExtension(TN_VIRTUAL_GEOMETRY);
1605
+ if (virtual === null) continue;
1606
+ const meshIndex = context.meshIndexMap.get(mesh);
1607
+ if (meshIndex === void 0) continue;
1608
+ const primitiveIndex = mesh.listPrimitives().indexOf(primitive);
1609
+ const primitiveDef = context.jsonDoc.json.meshes?.[meshIndex]?.primitives?.[primitiveIndex];
1610
+ if (primitiveDef === void 0) continue;
1611
+ const index = (accessor, field) => {
1612
+ const slot = accessor === null ? void 0 : context.accessorIndexMap.get(accessor);
1613
+ if (slot === void 0)
1614
+ throw new Error(
1615
+ `TN_VIRTUAL_GEOMETRY_ACCESSOR_MISSING: '${field}' on mesh '${mesh.getName()}' has no index in the written file.`
1616
+ );
1617
+ return slot;
1618
+ };
1619
+ primitiveDef.extensions = primitiveDef.extensions ?? {};
1620
+ primitiveDef.extensions[TN_VIRTUAL_GEOMETRY] = {
1621
+ clusterBounds: index(virtual.getClusterBounds(), "clusterBounds"),
1622
+ clusterCones: index(virtual.getClusterCones(), "clusterCones"),
1623
+ clusterErrors: index(virtual.getClusterErrors(), "clusterErrors"),
1624
+ clusterGroups: index(virtual.getClusterGroups(), "clusterGroups"),
1625
+ clusterParentSpheres: index(virtual.getClusterParentSpheres(), "clusterParentSpheres"),
1626
+ clusterRanges: index(virtual.getClusterRanges(), "clusterRanges"),
1627
+ clusterSourceSpheres: index(virtual.getClusterSourceSpheres(), "clusterSourceSpheres"),
1628
+ indices: index(virtual.getIndices(), "indices"),
1629
+ levelTriangles: virtual.getLevelTriangles(),
1630
+ stopReason: virtual.getStopReason()
1631
+ };
1632
+ }
1633
+ }
1634
+ return this;
1635
+ }
1636
+ };
1637
+ function accessorsOf(virtual) {
1638
+ return [
1639
+ virtual.getIndices(),
1640
+ virtual.getClusterRanges(),
1641
+ virtual.getClusterErrors(),
1642
+ virtual.getClusterBounds(),
1643
+ virtual.getClusterCones(),
1644
+ virtual.getClusterGroups(),
1645
+ virtual.getClusterSourceSpheres(),
1646
+ virtual.getClusterParentSpheres()
1647
+ ].filter((accessor) => accessor !== null);
1648
+ }
1649
+ function attachVirtualGeometry(document, extension, primitive, dag) {
1650
+ const buffer = document.getRoot().listBuffers()[0] ?? document.createBuffer();
1651
+ const count = dag.clusters.length;
1652
+ const ranges = new Uint32Array(count * 2);
1653
+ const errors = new Float32Array(count * 2);
1654
+ const bounds = new Float32Array(count * 4);
1655
+ const cones = new Float32Array(count * 4);
1656
+ const groups = new Uint32Array(count * 2);
1657
+ const sourceSpheres = new Float32Array(count * 4);
1658
+ const parentSpheres = new Float32Array(count * 4);
1659
+ const sourceGroups = new Uint32Array(count).fill(NO_GROUP);
1660
+ for (let group = 0; group < dag.groups.length; group += 1)
1661
+ for (const parent of dag.groups[group].parents)
1662
+ sourceGroups[parent] = group;
1663
+ for (let cluster = 0; cluster < count; cluster += 1) {
1664
+ const record = dag.clusters[cluster];
1665
+ ranges[cluster * 2] = record.start;
1666
+ ranges[cluster * 2 + 1] = record.count;
1667
+ errors[cluster * 2] = record.error;
1668
+ errors[cluster * 2 + 1] = Number.isFinite(record.parentError) ? record.parentError : ROOT_PARENT_ERROR;
1669
+ bounds[cluster * 4] = record.bounds.centerX;
1670
+ bounds[cluster * 4 + 1] = record.bounds.centerY;
1671
+ bounds[cluster * 4 + 2] = record.bounds.centerZ;
1672
+ bounds[cluster * 4 + 3] = record.bounds.radius;
1673
+ cones[cluster * 4] = record.bounds.coneAxisX;
1674
+ cones[cluster * 4 + 1] = record.bounds.coneAxisY;
1675
+ cones[cluster * 4 + 2] = record.bounds.coneAxisZ;
1676
+ cones[cluster * 4 + 3] = record.bounds.coneCutoff;
1677
+ groups[cluster * 2] = record.group === -1 ? NO_GROUP : record.group;
1678
+ groups[cluster * 2 + 1] = sourceGroups[cluster];
1679
+ sourceSpheres[cluster * 4] = record.sourceSphere.x;
1680
+ sourceSpheres[cluster * 4 + 1] = record.sourceSphere.y;
1681
+ sourceSpheres[cluster * 4 + 2] = record.sourceSphere.z;
1682
+ sourceSpheres[cluster * 4 + 3] = record.sourceSphere.radius;
1683
+ parentSpheres[cluster * 4] = record.parentSphere.x;
1684
+ parentSpheres[cluster * 4 + 1] = record.parentSphere.y;
1685
+ parentSpheres[cluster * 4 + 2] = record.parentSphere.z;
1686
+ parentSpheres[cluster * 4 + 3] = record.parentSphere.radius;
1687
+ }
1688
+ const accessor = (array, type) => document.createAccessor().setArray(array).setType(type).setBuffer(buffer);
1689
+ const virtual = extension.createVirtualGeometry().setIndices(accessor(dag.indices.slice(), "SCALAR")).setClusterRanges(accessor(ranges, "VEC2")).setClusterErrors(accessor(errors, "VEC2")).setClusterBounds(accessor(bounds, "VEC4")).setClusterCones(accessor(cones, "VEC4")).setClusterGroups(accessor(groups, "VEC2")).setClusterSourceSpheres(accessor(sourceSpheres, "VEC4")).setClusterParentSpheres(accessor(parentSpheres, "VEC4")).setLevelTriangles(dag.levels.map((level) => level.triangleCount)).setStopReason(dag.stopReason);
1690
+ primitive.setExtension(TN_VIRTUAL_GEOMETRY, virtual);
1691
+ return virtual;
1692
+ }
1693
+ function virtualGeometryBytes(virtual) {
1694
+ let bytes = 0;
1695
+ for (const accessor of accessorsOf(virtual)) {
1696
+ const array = accessor.getArray();
1697
+ bytes += array === null ? 0 : array.byteLength;
1698
+ }
1699
+ return bytes;
1700
+ }
1701
+
1702
+ // src/virtual/bake.ts
1703
+ var VIRTUAL_BAKE_VERSION = 3;
1704
+ var DEFAULT_MIN_SOURCE_TRIANGLES = 65536;
1705
+ function positionsOf(primitive) {
1706
+ const position = primitive.getAttribute("POSITION");
1707
+ if (position === null || position.getType() !== "VEC3") return null;
1708
+ const array = position.getArray();
1709
+ if (array === null) return null;
1710
+ if (array instanceof Float32Array && !position.getNormalized()) return array;
1711
+ const floats = new Float32Array(position.getCount() * 3);
1712
+ const element = [0, 0, 0];
1713
+ for (let vertex = 0; vertex < position.getCount(); vertex += 1) {
1714
+ position.getElement(vertex, element);
1715
+ floats[vertex * 3] = element[0];
1716
+ floats[vertex * 3 + 1] = element[1];
1717
+ floats[vertex * 3 + 2] = element[2];
1718
+ }
1719
+ return floats;
1720
+ }
1721
+ var WORST_FIRST = ["cap", "stalled", "root"];
1722
+ function worse(left, right) {
1723
+ return WORST_FIRST.indexOf(left) <= WORST_FIRST.indexOf(right) ? left : right;
1724
+ }
1725
+ async function bakeVirtualGeometry(document, options, now = () => Date.now()) {
1726
+ const started = now();
1727
+ const minSourceTriangles = options.minSourceTriangles ?? DEFAULT_MIN_SOURCE_TRIANGLES;
1728
+ const dagOptions = {
1729
+ ...options.groupSize === void 0 ? {} : { groupSize: options.groupSize },
1730
+ ...options.maxTriangles === void 0 ? {} : { maxTriangles: options.maxTriangles },
1731
+ ...options.minTriangles === void 0 ? {} : { minTriangles: options.minTriangles },
1732
+ ...options.simplifyRatio === void 0 ? {} : { simplifyRatio: options.simplifyRatio }
1733
+ };
1734
+ let extension = null;
1735
+ let clusters = 0;
1736
+ let levels = 0;
1737
+ let payloadBytes = 0;
1738
+ let primitives = 0;
1739
+ let skipped = 0;
1740
+ let stopReason = "root";
1741
+ for (const mesh of document.getRoot().listMeshes()) {
1742
+ for (const primitive of mesh.listPrimitives()) {
1743
+ const indices = primitive.getIndices();
1744
+ const positions = positionsOf(primitive);
1745
+ if (indices === null || positions === null || primitive.getMode() !== 4) {
1746
+ skipped += 1;
1747
+ continue;
1748
+ }
1749
+ if (indices.getCount() / 3 < minSourceTriangles) {
1750
+ skipped += 1;
1751
+ continue;
1752
+ }
1753
+ const dag = await buildClusterDag(
1754
+ Uint32Array.from(indices.getArray()),
1755
+ positions,
1756
+ dagOptions
1757
+ );
1758
+ extension ??= document.createExtension(TNVirtualGeometry).setRequired(false);
1759
+ const virtual = attachVirtualGeometry(document, extension, primitive, dag);
1760
+ clusters += dag.clusters.length;
1761
+ levels = Math.max(levels, dag.levels.length);
1762
+ payloadBytes += virtualGeometryBytes(virtual);
1763
+ primitives += 1;
1764
+ stopReason = worse(stopReason, dag.stopReason);
1765
+ }
1766
+ }
1767
+ return {
1768
+ bakeSeconds: (now() - started) / 1e3,
1769
+ clusters,
1770
+ levels,
1771
+ payloadBytes,
1772
+ primitives,
1773
+ skipped,
1774
+ stopReason
1775
+ };
1776
+ }
1777
+ var DEFAULT_MAX_SIZE = 2048;
1778
+ var DEFAULT_ETC1S_QUALITY2 = 150;
1779
+ var BLOCK_SIZE2 = 4;
1780
+ var MIP_FACTOR = 4 / 3;
1781
+ var GPU_BYTES_PER_PIXEL = {
1782
+ // BC1 from ETC1S, BC7 from UASTC, RGBA8 for anything left uncompressed.
1783
+ etc1s: 0.5,
1784
+ none: 4,
1785
+ uastc: 1
1786
+ };
1787
+ var NORMAL_SLOTS = /* @__PURE__ */ new Set(["clearcoatNormalTexture", "normalTexture"]);
1788
+ var CORE_SLOTS = [
1789
+ "baseColorTexture",
1790
+ "emissiveTexture",
1791
+ "metallicRoughnessTexture",
1792
+ "normalTexture",
1793
+ "occlusionTexture"
1794
+ ];
1795
+ function coreSlotTexture(material, slot) {
1796
+ switch (slot) {
1797
+ case "baseColorTexture":
1798
+ return material.getBaseColorTexture();
1799
+ case "emissiveTexture":
1800
+ return material.getEmissiveTexture();
1801
+ case "metallicRoughnessTexture":
1802
+ return material.getMetallicRoughnessTexture();
1803
+ case "normalTexture":
1804
+ return material.getNormalTexture();
1805
+ default:
1806
+ return material.getOcclusionTexture();
1807
+ }
1808
+ }
1809
+ function coreSlotTexCoord(material, slot) {
1810
+ switch (slot) {
1811
+ case "baseColorTexture":
1812
+ return material.getBaseColorTextureInfo()?.getTexCoord();
1813
+ case "emissiveTexture":
1814
+ return material.getEmissiveTextureInfo()?.getTexCoord();
1815
+ case "metallicRoughnessTexture":
1816
+ return material.getMetallicRoughnessTextureInfo()?.getTexCoord();
1817
+ case "normalTexture":
1818
+ return material.getNormalTextureInfo()?.getTexCoord();
1819
+ default:
1820
+ return material.getOcclusionTextureInfo()?.getTexCoord();
1821
+ }
1822
+ }
1823
+ function textureKeys(root) {
1824
+ const textures = root.listTextures();
1825
+ const seen = /* @__PURE__ */ new Map();
1826
+ return textures.map((texture, index) => {
1827
+ const name = texture.getName();
1828
+ const base = name === "" ? `texture#${String(index)}` : name;
1829
+ const count = seen.get(base) ?? 0;
1830
+ seen.set(base, count + 1);
1831
+ return count === 0 ? base : `${base}#${String(index)}`;
1832
+ });
1833
+ }
1834
+ function imageShape(bytes, mimeType) {
1835
+ if (mimeType === "image/ktx2") {
1836
+ const container = read(bytes);
1837
+ return {
1838
+ height: container.pixelHeight,
1839
+ levels: container.levelCount,
1840
+ width: container.pixelWidth
1841
+ };
1842
+ }
1843
+ const stats = textureStats(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
1844
+ return { height: stats.height, levels: 1, width: stats.width };
1845
+ }
1846
+ function textureBindings(root) {
1847
+ const textures = root.listTextures();
1848
+ const keys = textureKeys(root);
1849
+ return {
1850
+ materials: root.listMaterials().flatMap((material, materialIndex) => {
1851
+ const name = material.getName();
1852
+ const label = name === "" ? `material#${String(materialIndex)}` : name;
1853
+ return CORE_SLOTS.flatMap((slot) => {
1854
+ const texture = coreSlotTexture(material, slot);
1855
+ if (texture === null) return [];
1856
+ const index = textures.indexOf(texture);
1857
+ const texCoord = coreSlotTexCoord(material, slot) ?? 0;
1858
+ return [`${label}.${slot}=${String(index)}@uv${String(texCoord)}`];
1859
+ });
1860
+ }),
1861
+ textures: textures.map((texture, index) => {
1862
+ const image = texture.getImage();
1863
+ const mimeType = texture.getMimeType();
1864
+ const shape = image === null ? { height: 0, levels: 0, width: 0 } : imageShape(image, mimeType);
1865
+ return {
1866
+ height: shape.height,
1867
+ key: keys[index] ?? `texture#${String(index)}`,
1868
+ levels: shape.levels,
1869
+ mimeType,
1870
+ slots: [...listTextureSlots(texture)].sort(),
1871
+ texCoords: [...new Set(listTextureInfo(texture).map((info) => info.getTexCoord()))].sort(
1872
+ (left, right) => left - right
1873
+ ),
1874
+ width: shape.width
1875
+ };
1876
+ })
1877
+ };
1878
+ }
1879
+ function describeBinding(binding) {
1880
+ return `${binding.key} (${binding.mimeType} ${String(binding.width)}x${String(binding.height)}, slots ${binding.slots.join("+") || "none"}, uv ${binding.texCoords.join("+") || "none"})`;
1881
+ }
1882
+ function assertNoTextureDrift(source, output, logicalPath) {
1883
+ const failures = [];
1884
+ if (source.textures.length !== output.textures.length) {
1885
+ failures.push(
1886
+ `texture count ${String(source.textures.length)} -> ${String(output.textures.length)}`
1887
+ );
1888
+ }
1889
+ const count = Math.min(source.textures.length, output.textures.length);
1890
+ for (let index = 0; index < count; index += 1) {
1891
+ const before = source.textures[index];
1892
+ const after = output.textures[index];
1893
+ if (before === void 0 || after === void 0) continue;
1894
+ if (before.key !== after.key) {
1895
+ failures.push(`texture ${String(index)} renamed ${before.key} -> ${after.key}`);
1896
+ continue;
1897
+ }
1898
+ if (after.levels === 0) {
1899
+ failures.push(`${before.key} lost its image`);
1900
+ continue;
1901
+ }
1902
+ if (before.slots.join(",") !== after.slots.join(",")) {
1903
+ failures.push(`${describeBinding(before)} -> slots ${after.slots.join("+") || "none"}`);
1904
+ }
1905
+ if (before.texCoords.join(",") !== after.texCoords.join(",")) {
1906
+ failures.push(`${describeBinding(before)} -> uv ${after.texCoords.join("+") || "none"}`);
1907
+ }
1908
+ }
1909
+ const missing = source.materials.filter((binding) => !output.materials.includes(binding));
1910
+ const added = output.materials.filter((binding) => !source.materials.includes(binding));
1911
+ if (missing.length > 0) failures.push(`material bindings lost: ${missing.join(", ")}`);
1912
+ if (added.length > 0) failures.push(`material bindings appeared: ${added.join(", ")}`);
1913
+ if (failures.length > 0) {
1914
+ throw new Error(
1915
+ `TN_ASSETS_MODEL_TEXTURE_DRIFT: embedded-texture self-verification failed for '${logicalPath}': ${failures.join("; ")}.`
1916
+ );
1917
+ }
1918
+ }
1919
+ var SRGB_TO_LINEAR = new Float32Array(256);
1920
+ for (let value = 0; value < 256; value += 1) {
1921
+ const channel = value / 255;
1922
+ SRGB_TO_LINEAR[value] = channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
1923
+ }
1924
+ function linearToSrgb(channel) {
1925
+ const encoded = channel <= 31308e-7 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055;
1926
+ return Math.max(0, Math.min(255, Math.round(encoded * 255)));
1927
+ }
1928
+ function resampleRgba(data, width, height, targetWidth, targetHeight, srgb) {
1929
+ const output = new Uint8Array(targetWidth * targetHeight * 4);
1930
+ const scaleX = width / targetWidth;
1931
+ const scaleY = height / targetHeight;
1932
+ for (let ty = 0; ty < targetHeight; ty += 1) {
1933
+ const y0 = ty * scaleY;
1934
+ const y1 = (ty + 1) * scaleY;
1935
+ for (let tx = 0; tx < targetWidth; tx += 1) {
1936
+ const x0 = tx * scaleX;
1937
+ const x1 = (tx + 1) * scaleX;
1938
+ let red = 0;
1939
+ let green = 0;
1940
+ let blue = 0;
1941
+ let alpha = 0;
1942
+ let total = 0;
1943
+ for (let sy = Math.floor(y0); sy < Math.min(height, Math.ceil(y1)); sy += 1) {
1944
+ const weightY = Math.min(y1, sy + 1) - Math.max(y0, sy);
1945
+ if (weightY <= 0) continue;
1946
+ for (let sx = Math.floor(x0); sx < Math.min(width, Math.ceil(x1)); sx += 1) {
1947
+ const weightX = Math.min(x1, sx + 1) - Math.max(x0, sx);
1948
+ if (weightX <= 0) continue;
1949
+ const weight = weightX * weightY;
1950
+ const offset = (sy * width + sx) * 4;
1951
+ const r = data[offset] ?? 0;
1952
+ const g = data[offset + 1] ?? 0;
1953
+ const b = data[offset + 2] ?? 0;
1954
+ red += (srgb ? SRGB_TO_LINEAR[r] ?? 0 : r) * weight;
1955
+ green += (srgb ? SRGB_TO_LINEAR[g] ?? 0 : g) * weight;
1956
+ blue += (srgb ? SRGB_TO_LINEAR[b] ?? 0 : b) * weight;
1957
+ alpha += (data[offset + 3] ?? 255) * weight;
1958
+ total += weight;
1959
+ }
1960
+ }
1961
+ const divisor = total === 0 ? 1 : total;
1962
+ const target = (ty * targetWidth + tx) * 4;
1963
+ output[target] = srgb ? linearToSrgb(red / divisor) : Math.round(red / divisor);
1964
+ output[target + 1] = srgb ? linearToSrgb(green / divisor) : Math.round(green / divisor);
1965
+ output[target + 2] = srgb ? linearToSrgb(blue / divisor) : Math.round(blue / divisor);
1966
+ output[target + 3] = Math.round(alpha / divisor);
1967
+ }
1968
+ }
1969
+ return output;
1970
+ }
1971
+ function cappedSize(width, height, maxSize) {
1972
+ const longest = Math.max(width, height);
1973
+ if (longest <= maxSize) return { height, width };
1974
+ const scale2 = maxSize / longest;
1975
+ const snap = (value) => Math.max(BLOCK_SIZE2, Math.round(value * scale2 / BLOCK_SIZE2) * BLOCK_SIZE2);
1976
+ return { height: snap(height), width: snap(width) };
1977
+ }
1978
+ function rgbaHasAlpha2(rgba) {
1979
+ for (let offset = 3; offset < rgba.length; offset += 4) {
1980
+ if ((rgba[offset] ?? 255) !== 255) return true;
1981
+ }
1982
+ return false;
1983
+ }
1984
+ function chooseCodec2(slots, alpha, options) {
1985
+ for (const override of options.overrides ?? []) {
1986
+ if (slots.includes(override.slot)) return override.codec;
1987
+ }
1988
+ if (slots.some((slot) => NORMAL_SLOTS.has(slot))) return "uastc";
1989
+ return alpha ? "uastc" : "etc1s";
1990
+ }
1991
+ function gpuBytes(width, height, codec) {
1992
+ return Math.round(width * height * (GPU_BYTES_PER_PIXEL[codec] ?? 4) * MIP_FACTOR);
1993
+ }
1994
+ function messageOf2(error) {
1995
+ return error instanceof Error ? error.message : String(error);
1996
+ }
1997
+ async function compressEmbeddedTextures(document, logicalPath, options = {}) {
1998
+ const root = document.getRoot();
1999
+ const textures = root.listTextures();
2000
+ if (textures.length === 0) return void 0;
2001
+ const maxSize = options.maxSize ?? DEFAULT_MAX_SIZE;
2002
+ const quality = Math.min(255, Math.max(1, Math.round(options.quality ?? DEFAULT_ETC1S_QUALITY2)));
2003
+ const keys = textureKeys(root);
2004
+ const formats = {};
2005
+ let bytesBefore = 0;
2006
+ let bytesAfter = 0;
2007
+ let gpuBytesBefore = 0;
2008
+ let gpuBytesAfter = 0;
2009
+ let resized = 0;
2010
+ let compressed = 0;
2011
+ for (const [index, texture] of textures.entries()) {
2012
+ const key = keys[index] ?? `texture#${String(index)}`;
2013
+ const image = texture.getImage();
2014
+ if (image === null) {
2015
+ throw new Error(
2016
+ `TN_ASSETS_MODEL_TEXTURE_MISSING: '${logicalPath}' declares texture '${key}' with no image data.`
2017
+ );
2018
+ }
2019
+ if (texture.getMimeType() === "image/ktx2") {
2020
+ const shape = imageShape(image, "image/ktx2");
2021
+ bytesBefore += image.byteLength;
2022
+ bytesAfter += image.byteLength;
2023
+ const already = gpuBytes(shape.width, shape.height, "uastc");
2024
+ gpuBytesBefore += already;
2025
+ gpuBytesAfter += already;
2026
+ continue;
2027
+ }
2028
+ const slots = listTextureSlots(texture);
2029
+ let decoded;
2030
+ try {
2031
+ decoded = await decodeImageBytes(
2032
+ Buffer.from(image.buffer, image.byteOffset, image.byteLength),
2033
+ `${logicalPath}#${key}`
2034
+ );
2035
+ } catch (error) {
2036
+ throw new Error(
2037
+ `TN_ASSETS_MODEL_TEXTURE_UNDECODABLE: could not decode embedded texture '${key}' of '${logicalPath}': ${messageOf2(error)}`
2038
+ );
2039
+ }
2040
+ const codec = chooseCodec2(slots, rgbaHasAlpha2(decoded.data), options);
2041
+ bytesBefore += image.byteLength;
2042
+ gpuBytesBefore += gpuBytes(decoded.width, decoded.height, "none");
2043
+ if (codec === "none") {
2044
+ bytesAfter += image.byteLength;
2045
+ gpuBytesAfter += gpuBytes(decoded.width, decoded.height, "none");
2046
+ formats[key] = "none";
2047
+ continue;
2048
+ }
2049
+ const target = cappedSize(decoded.width, decoded.height, maxSize);
2050
+ const srgb = getTextureColorSpace(texture) === "srgb";
2051
+ let data = decoded.data;
2052
+ if (target.width !== decoded.width || target.height !== decoded.height) {
2053
+ data = resampleRgba(
2054
+ decoded.data,
2055
+ decoded.width,
2056
+ decoded.height,
2057
+ target.width,
2058
+ target.height,
2059
+ srgb
2060
+ );
2061
+ resized += 1;
2062
+ }
2063
+ if (target.width % BLOCK_SIZE2 !== 0 || target.height % BLOCK_SIZE2 !== 0) {
2064
+ throw new Error(
2065
+ `TN_ASSETS_MODEL_TEXTURE_BLOCK_SIZE: embedded texture '${key}' of '${logicalPath}' is ${String(target.width)}x${String(target.height)}, which is not a multiple of the ${String(BLOCK_SIZE2)}x${String(BLOCK_SIZE2)} block the ${codec} codec transcodes to; WebGPU rejects such a texture at draw time. Resize the source to a multiple of ${String(BLOCK_SIZE2)} inside the model, or declare an override with codec "none" for its slot.`
2066
+ );
2067
+ }
2068
+ const normalMap = slots.some((slot) => NORMAL_SLOTS.has(slot));
2069
+ const encoded = await encodeToKTX2(new Uint8Array([0]), {
2070
+ generateMipmap: true,
2071
+ imageDecoder: async () => ({ data, height: target.height, width: target.width }),
2072
+ isPerceptual: srgb,
2073
+ isSetKTX2SRGBTransferFunc: srgb,
2074
+ ...codec === "uastc" ? { isUASTC: true, ...normalMap ? { isNormalMap: true } : {} } : { isUASTC: false, qualityLevel: quality }
2075
+ });
2076
+ const container = read(encoded);
2077
+ if (container.levelCount < 2) {
2078
+ throw new Error(
2079
+ `TN_ASSETS_MIP_CHAIN_INCOMPLETE: embedded texture '${key}' of '${logicalPath}' encoded without a mip chain (${String(container.levelCount)} level(s)).`
2080
+ );
2081
+ }
2082
+ texture.setImage(encoded).setMimeType("image/ktx2");
2083
+ formats[key] = codec;
2084
+ bytesAfter += encoded.byteLength;
2085
+ gpuBytesAfter += gpuBytes(target.width, target.height, codec);
2086
+ compressed += 1;
2087
+ }
2088
+ if (compressed > 0) {
2089
+ document.createExtension(KHRTextureBasisu).setRequired(true);
2090
+ }
2091
+ return {
2092
+ bytesAfter,
2093
+ bytesBefore,
2094
+ count: textures.length,
2095
+ formats,
2096
+ gpuBytesAfter,
2097
+ gpuBytesBefore,
2098
+ resized
2099
+ };
2100
+ }
2101
+
2102
+ // src/passes/model.ts
2103
+ var DRACO_EXTENSION = "KHR_draco_mesh_compression";
2104
+ var BBOX_TOLERANCE = 1e-3;
2105
+ var SIMPLIFY_BBOX_TOLERANCE = 0.01;
2106
+ var SIMPLIFY_RATIO_FLOOR = 0.5;
2107
+ var DEFAULT_SIMPLIFY_ERROR = 1e-3;
2108
+ var DEFAULT_POSITION_BITS = 16;
2109
+ var DEFAULT_NORMAL_BITS = 8;
2110
+ var DEFAULT_UV_BITS = 12;
2111
+ function peekExtensions(input) {
2112
+ try {
2113
+ const json = input.subarray(0, 4).toString("ascii") === "glTF" ? JSON.parse(
2114
+ input.subarray(20, 20 + input.readUInt32LE(12)).toString("utf8")
2115
+ ) : JSON.parse(input.toString("utf8"));
2116
+ return /* @__PURE__ */ new Set([...json.extensionsUsed ?? [], ...json.extensionsRequired ?? []]);
2117
+ } catch {
2118
+ return /* @__PURE__ */ new Set();
2119
+ }
2120
+ }
2121
+ function jsonOfGlb(binary) {
2122
+ return JSON.parse(
2123
+ binary.subarray(20, 20 + binary.readUInt32LE(12)).toString("utf8")
2124
+ );
2125
+ }
2126
+ async function createIo() {
2127
+ await MeshoptDecoder.ready;
2128
+ const io = new NodeIO().registerExtensions([...ALL_EXTENSIONS, TNVirtualGeometry]).registerDependencies({ "meshopt.decoder": MeshoptDecoder });
2129
+ return io;
2130
+ }
2131
+ async function readDocument2(input, logicalPath) {
2132
+ let io = await createIo();
2133
+ if (peekExtensions(input).has(DRACO_EXTENSION)) {
2134
+ const { createDecoderModule } = await import('draco3dgltf');
2135
+ io = io.registerDependencies({ "draco3d.decoder": await createDecoderModule() });
2136
+ }
2137
+ try {
2138
+ const document = input.subarray(0, 4).toString("ascii") === "glTF" ? await io.readJSON(await io.binaryToJSON(input)) : await io.readJSON({
2139
+ json: JSON.parse(input.toString("utf8")),
2140
+ resources: {}
2141
+ });
2142
+ return document;
2143
+ } catch (error) {
2144
+ const detail = error instanceof Error ? error.message : String(error);
2145
+ throw new Error(
2146
+ `TN_ASSETS_MODEL_UNREADABLE: could not parse '${logicalPath}' for the model pass: ${detail}. External buffer or image URIs are not supported; use a self-contained .glb.`
2147
+ );
2148
+ }
2149
+ }
2150
+ async function writeDocument(document, logicalPath) {
2151
+ await MeshoptEncoder.ready;
2152
+ const io = new NodeIO().registerExtensions([...ALL_EXTENSIONS, TNVirtualGeometry]).registerDependencies({ "meshopt.encoder": MeshoptEncoder });
2153
+ try {
2154
+ const buffer = Buffer.from(await io.writeBinary(document));
2155
+ return { buffer, extensions: jsonOfGlb(buffer).extensionsUsed ?? [] };
2156
+ } catch (error) {
2157
+ const detail = error instanceof Error ? error.message : String(error);
2158
+ throw new Error(`TN_ASSETS_MODEL_WRITE_FAILED: could not write '${logicalPath}': ${detail}`);
2159
+ }
2160
+ }
2161
+ function primitiveTriangles2(primitive) {
2162
+ const drawn = primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION")?.getCount() ?? 0;
2163
+ return Math.floor(drawn / 3);
2164
+ }
2165
+ function decodeElement(accessor, index) {
2166
+ const array = accessor.getArray();
2167
+ const base = index * accessor.getElementSize();
2168
+ const x = array[base] ?? 0;
2169
+ const y = array[base + 1] ?? 0;
2170
+ const z = array[base + 2] ?? 0;
2171
+ if (!accessor.getNormalized()) return [x, y, z];
2172
+ switch (accessor.getComponentType()) {
2173
+ case Accessor.ComponentType.BYTE:
2174
+ return [Math.max(x / 127, -1), Math.max(y / 127, -1), Math.max(z / 127, -1)];
2175
+ case Accessor.ComponentType.UNSIGNED_BYTE:
2176
+ return [x / 255, y / 255, z / 255];
2177
+ case Accessor.ComponentType.SHORT:
2178
+ return [Math.max(x / 32767, -1), Math.max(y / 32767, -1), Math.max(z / 32767, -1)];
2179
+ case Accessor.ComponentType.UNSIGNED_SHORT:
2180
+ return [x / 65535, y / 65535, z / 65535];
2181
+ default:
2182
+ return [x, y, z];
2183
+ }
2184
+ }
2185
+ function normalizedWeightScale(accessor) {
2186
+ switch (accessor.getComponentType()) {
2187
+ case Accessor.ComponentType.BYTE:
2188
+ return 1 / 127;
2189
+ case Accessor.ComponentType.UNSIGNED_BYTE:
2190
+ return 1 / 255;
2191
+ case Accessor.ComponentType.SHORT:
2192
+ return 1 / 32767;
2193
+ case Accessor.ComponentType.UNSIGNED_SHORT:
2194
+ return 1 / 65535;
2195
+ default:
2196
+ return 1;
2197
+ }
2198
+ }
2199
+ function evaluateVertex(position, index, joints, weights, jointMatrices, fallbackMatrix) {
2200
+ const [x, y, z] = decodeElement(position, index);
2201
+ if (jointMatrices === void 0 || joints === null || weights === null) {
2202
+ return transformPoint(fallbackMatrix, x, y, z);
2203
+ }
2204
+ const jointArray = joints.getArray();
2205
+ const weightArray = weights.getArray();
2206
+ const weightScale = weights.getNormalized() ? normalizedWeightScale(weights) : 1;
2207
+ let wx = 0;
2208
+ let wy = 0;
2209
+ let wz = 0;
2210
+ for (let slot = 0; slot < 4; slot += 1) {
2211
+ const base = index * 4 + slot;
2212
+ const weight = (weightArray[base] ?? 0) * weightScale;
2213
+ if (weight === 0) continue;
2214
+ const matrix = jointMatrices[jointArray[base] ?? 0];
2215
+ if (matrix === void 0) continue;
2216
+ const point = transformPoint(matrix, x, y, z);
2217
+ wx += point[0] * weight;
2218
+ wy += point[1] * weight;
2219
+ wz += point[2] * weight;
2220
+ }
2221
+ if (wx === 0 && wy === 0 && wz === 0) return transformPoint(fallbackMatrix, x, y, z);
2222
+ return [wx, wy, wz];
2223
+ }
2224
+ function transformPoint(m, x, y, z) {
2225
+ const c = (index) => m[index] ?? 0;
2226
+ return [
2227
+ c(0) * x + c(4) * y + c(8) * z + c(12),
2228
+ c(1) * x + c(5) * y + c(9) * z + c(13),
2229
+ c(2) * x + c(6) * y + c(10) * z + c(14)
2230
+ ];
2231
+ }
2232
+ function multiplyMatrices(a, b) {
2233
+ const out = new Array(16);
2234
+ for (let column = 0; column < 4; column += 1) {
2235
+ for (let row = 0; row < 4; row += 1) {
2236
+ const c = (index) => a[index] ?? 0;
2237
+ const d = (index) => b[index] ?? 0;
2238
+ out[column * 4 + row] = c(row) * d(column * 4) + c(4 + row) * d(column * 4 + 1) + c(8 + row) * d(column * 4 + 2) + c(12 + row) * d(column * 4 + 3);
2239
+ }
2240
+ }
2241
+ return out;
2242
+ }
2243
+ var IDENTITY_MATRIX = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
2244
+ function reachableStats(root) {
2245
+ let minX = Number.POSITIVE_INFINITY;
2246
+ let minY = Number.POSITIVE_INFINITY;
2247
+ let minZ = Number.POSITIVE_INFINITY;
2248
+ let maxX = Number.NEGATIVE_INFINITY;
2249
+ let maxY = Number.NEGATIVE_INFINITY;
2250
+ let maxZ = Number.NEGATIVE_INFINITY;
2251
+ let triangles = 0;
2252
+ let vertices = 0;
2253
+ const skins = /* @__PURE__ */ new Set();
2254
+ const visited = /* @__PURE__ */ new Set();
2255
+ const walk = (node) => {
2256
+ if (visited.has(node)) return;
2257
+ visited.add(node);
2258
+ const skin = node.getSkin();
2259
+ if (skin !== null) skins.add(skin);
2260
+ const mesh = node.getMesh();
2261
+ if (mesh !== null) {
2262
+ const worldMatrix = node.getWorldMatrix();
2263
+ let jointMatrices;
2264
+ if (skin !== null) {
2265
+ const inverseBinds = skin.getInverseBindMatrices();
2266
+ jointMatrices = skin.listJoints().map((joint, index) => {
2267
+ const jointWorld = joint.getWorldMatrix();
2268
+ const ibm = inverseBinds?.getElement(index, [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) ?? IDENTITY_MATRIX;
2269
+ return multiplyMatrices(jointWorld, ibm);
2270
+ });
2271
+ }
2272
+ for (const primitive of mesh.listPrimitives()) {
2273
+ triangles += primitiveTriangles2(primitive);
2274
+ const position = primitive.getAttribute("POSITION");
2275
+ if (position === null) continue;
2276
+ vertices += position.getCount();
2277
+ for (let index = 0; index < position.getCount(); index += 1) {
2278
+ const [wx, wy, wz] = evaluateVertex(
2279
+ position,
2280
+ index,
2281
+ primitive.getAttribute("JOINTS_0"),
2282
+ primitive.getAttribute("WEIGHTS_0"),
2283
+ skin !== null ? jointMatrices : void 0,
2284
+ worldMatrix
2285
+ );
2286
+ minX = Math.min(minX, wx);
2287
+ minY = Math.min(minY, wy);
2288
+ minZ = Math.min(minZ, wz);
2289
+ maxX = Math.max(maxX, wx);
2290
+ maxY = Math.max(maxY, wy);
2291
+ maxZ = Math.max(maxZ, wz);
2292
+ }
2293
+ }
2294
+ }
2295
+ node.listChildren().forEach(walk);
2296
+ };
2297
+ root.listScenes().flatMap((scene) => scene.listChildren()).forEach(walk);
2298
+ let joints = 0;
2299
+ const seenJoints = /* @__PURE__ */ new Set();
2300
+ for (const skin of skins) {
2301
+ for (const joint of skin.listJoints()) {
2302
+ if (seenJoints.has(joint)) continue;
2303
+ seenJoints.add(joint);
2304
+ joints += 1;
2305
+ }
2306
+ }
2307
+ const finite = Number.isFinite(minX) && Number.isFinite(maxZ);
2308
+ return {
2309
+ boundingBox: finite ? { max: [maxX, maxY, maxZ], min: [minX, minY, minZ] } : void 0,
2310
+ // A clip survives when something reachable is still animated by it.
2311
+ clips: root.listAnimations().filter(
2312
+ (animation) => animation.listChannels().some((channel) => {
2313
+ const targetNode = channel.getTargetNode();
2314
+ return targetNode !== null && visited.has(targetNode);
2315
+ })
2316
+ ).length,
2317
+ joints,
2318
+ triangles,
2319
+ vertices
2320
+ };
2321
+ }
2322
+ function jointComponentSizes(root) {
2323
+ let joints = 0;
2324
+ let weights = 0;
2325
+ for (const mesh of root.listMeshes()) {
2326
+ for (const primitive of mesh.listPrimitives()) {
2327
+ const jointsAccessor = primitive.getAttribute("JOINTS_0");
2328
+ if (jointsAccessor !== null) {
2329
+ const size = jointsAccessor.getComponentSize();
2330
+ joints = joints === 0 ? size : Math.min(joints, size);
2331
+ }
2332
+ const weightsAccessor = primitive.getAttribute("WEIGHTS_0");
2333
+ if (weightsAccessor !== null) {
2334
+ const size = weightsAccessor.getComponentSize();
2335
+ weights = weights === 0 ? size : Math.min(weights, size);
2336
+ }
2337
+ }
2338
+ }
2339
+ return { joints, weights };
2340
+ }
2341
+ function bboxDrift(left, right) {
2342
+ const leftBox = left.boundingBox;
2343
+ const rightBox = right.boundingBox;
2344
+ if (leftBox === void 0 || rightBox === void 0) {
2345
+ return leftBox === rightBox ? 0 : void 0;
2346
+ }
2347
+ const component = (box, corner, axis) => box?.[corner][axis] ?? 0;
2348
+ const scale2 = Math.max(
2349
+ ...[0, 1, 2].map((axis) => component(leftBox, "max", axis) - component(leftBox, "min", axis)),
2350
+ 1e-6
2351
+ );
2352
+ return Math.max(
2353
+ ...[0, 1, 2].map(
2354
+ (axis) => Math.max(
2355
+ Math.abs(component(leftBox, "min", axis) - component(rightBox, "min", axis)),
2356
+ Math.abs(component(leftBox, "max", axis) - component(rightBox, "max", axis))
2357
+ ) / scale2
2358
+ )
2359
+ );
2360
+ }
2361
+ function assertNoDrift(source, output, logicalPath) {
2362
+ const failures = [];
2363
+ if (source.triangles !== output.triangles) {
2364
+ failures.push(`triangles ${source.triangles} -> ${output.triangles}`);
2365
+ }
2366
+ if (source.vertices !== output.vertices) {
2367
+ failures.push(`vertices ${source.vertices} -> ${output.vertices}`);
2368
+ }
2369
+ if (source.joints !== output.joints) {
2370
+ failures.push(`joints ${source.joints} -> ${output.joints}`);
2371
+ }
2372
+ if (source.clips !== output.clips) {
2373
+ failures.push(`animation clips ${source.clips} -> ${output.clips}`);
2374
+ }
2375
+ const bbox = bboxDrift(source, output);
2376
+ if (bbox === void 0) {
2377
+ failures.push("bounding box lost");
2378
+ } else if (bbox > BBOX_TOLERANCE) {
2379
+ failures.push(
2380
+ `bounding box drifted ${(bbox * 100).toFixed(3)}% (tolerance ${(BBOX_TOLERANCE * 100).toFixed(1)}%)`
2381
+ );
2382
+ }
2383
+ if (failures.length > 0) {
2384
+ throw new Error(
2385
+ `TN_ASSETS_MODEL_DRIFT: self-verification failed for '${logicalPath}': ${failures.join("; ")}.`
2386
+ );
2387
+ }
2388
+ }
2389
+ function assertSimplifiedWithinBounds(source, output, ratio, logicalPath) {
2390
+ const failures = [];
2391
+ if (source.joints !== output.joints) {
2392
+ failures.push(`joints ${source.joints} -> ${output.joints}`);
2393
+ }
2394
+ if (source.clips !== output.clips) {
2395
+ failures.push(`animation clips ${source.clips} -> ${output.clips}`);
2396
+ }
2397
+ if (output.triangles > source.triangles) {
2398
+ failures.push(`triangles grew ${source.triangles} -> ${output.triangles}`);
2399
+ }
2400
+ const floor = Math.floor(source.triangles * ratio * SIMPLIFY_RATIO_FLOOR);
2401
+ if (output.triangles < floor) {
2402
+ failures.push(
2403
+ `triangles ${source.triangles} -> ${output.triangles}, below the ${String(floor)} floor for ratio ${String(ratio)}`
2404
+ );
2405
+ }
2406
+ const bbox = bboxDrift(source, output);
2407
+ if (bbox === void 0) {
2408
+ failures.push("bounding box lost");
2409
+ } else if (bbox > SIMPLIFY_BBOX_TOLERANCE) {
2410
+ failures.push(
2411
+ `bounding box drifted ${(bbox * 100).toFixed(3)}% (simplify tolerance ${(SIMPLIFY_BBOX_TOLERANCE * 100).toFixed(1)}%)`
2412
+ );
2413
+ }
2414
+ if (failures.length > 0) {
2415
+ throw new Error(
2416
+ `TN_ASSETS_MODEL_SIMPLIFY_DRIFT: self-verification failed for '${logicalPath}': ${failures.join("; ")}.`
2417
+ );
2418
+ }
2419
+ }
2420
+ function snapPositions(root, bits) {
2421
+ const levels = 2 ** bits - 1;
2422
+ for (const mesh of root.listMeshes()) {
2423
+ for (const primitive of mesh.listPrimitives()) {
2424
+ const position = primitive.getAttribute("POSITION");
2425
+ if (position === null || position.getNormalized()) continue;
2426
+ const array = position.getArray();
2427
+ const min = position.getMin([0, 0, 0]);
2428
+ const max = position.getMax([0, 0, 0]);
2429
+ const stride = position.getElementSize();
2430
+ const output = new Float32Array(array.length);
2431
+ for (let index = 0; index < position.getCount(); index += 1) {
2432
+ for (let axis = 0; axis < stride; axis += 1) {
2433
+ const source = array[index * stride + axis] ?? 0;
2434
+ const low = min[axis] ?? 0;
2435
+ const high = max[axis] ?? 0;
2436
+ const span = high - low;
2437
+ const fraction = span === 0 ? 0 : (source - low) / span;
2438
+ output[index * stride + axis] = low + Math.round(fraction * levels) / levels * span;
2439
+ }
2440
+ }
2441
+ position.setArray(output);
2442
+ }
2443
+ }
2444
+ }
2445
+ function modelPass(options = {}) {
2446
+ return {
2447
+ configuration: {
2448
+ passes: {
2449
+ dedup: options.passes?.dedup ?? true,
2450
+ meshopt: options.passes?.meshopt ?? true,
2451
+ prune: options.passes?.prune ?? true,
2452
+ quantize: options.passes?.quantize ?? true,
2453
+ reorder: options.passes?.reorder ?? true
2454
+ },
2455
+ quantize: {
2456
+ normalBits: options.quantize?.normalBits ?? DEFAULT_NORMAL_BITS,
2457
+ positionBits: options.quantize?.positionBits ?? DEFAULT_POSITION_BITS,
2458
+ uvBits: options.quantize?.uvBits ?? DEFAULT_UV_BITS
2459
+ },
2460
+ // Part of the compile cache key: change the cap or a codec and stale outputs must not
2461
+ // be re-served.
2462
+ simplify: options.simplify ?? null,
2463
+ // `"none"` and "absent" are different cache keys on purpose: absent bakes with defaults.
2464
+ virtual: options.virtual === "none" ? "none" : {
2465
+ ...options.virtual === void 0 ? {} : options.virtual,
2466
+ bakeVersion: VIRTUAL_BAKE_VERSION
2467
+ },
2468
+ textures: options.textures === "none" ? "none" : {
2469
+ maxSize: options.textures?.maxSize ?? null,
2470
+ overrides: options.textures?.overrides ?? [],
2471
+ quality: options.textures?.quality ?? null
2472
+ }
2473
+ },
2474
+ name: "model",
2475
+ apply: async (input, logicalPath) => {
2476
+ if (classify(logicalPath) !== "model") return input;
2477
+ const passes = options.passes ?? {};
2478
+ const enabled = {
2479
+ dedup: passes.dedup ?? true,
2480
+ meshopt: passes.meshopt ?? true,
2481
+ prune: passes.prune ?? true,
2482
+ quantize: passes.quantize ?? true,
2483
+ reorder: passes.reorder ?? true
2484
+ };
2485
+ const textureOptions = options.textures === "none" ? void 0 : options.textures ?? {};
2486
+ const virtualOptions = options.virtual === "none" ? void 0 : options.virtual ?? {};
2487
+ const geometryActive = Object.values(enabled).some(Boolean) || options.simplify !== void 0 || virtualOptions !== void 0;
2488
+ if (!geometryActive && textureOptions === void 0) return input;
2489
+ const document = await readDocument2(input, logicalPath);
2490
+ if (!geometryActive && document.getRoot().listTextures().length === 0) return input;
2491
+ for (const extension of document.getRoot().listExtensionsUsed()) {
2492
+ if (extension.extensionName === DRACO_EXTENSION) extension.dispose();
2493
+ }
2494
+ const source = reachableStats(document.getRoot());
2495
+ const jointFloor = jointComponentSizes(document.getRoot());
2496
+ if (enabled.dedup) await dedup()(document);
2497
+ if (enabled.prune)
2498
+ await prune({ keepAttributes: options.preserveLightmapUv === true })(document);
2499
+ if (options.simplify !== void 0) {
2500
+ await MeshoptSimplifier.ready;
2501
+ await simplify({
2502
+ error: options.simplify.error ?? DEFAULT_SIMPLIFY_ERROR,
2503
+ ratio: options.simplify.ratio,
2504
+ simplifier: MeshoptSimplifier
2505
+ })(document);
2506
+ }
2507
+ if (enabled.reorder || enabled.meshopt) await MeshoptEncoder.ready;
2508
+ if (enabled.reorder) await reorder({ encoder: MeshoptEncoder })(document);
2509
+ const virtual = virtualOptions === void 0 ? void 0 : await bakeVirtualGeometry(document, virtualOptions);
2510
+ if (enabled.quantize) {
2511
+ const requestedBits = options.quantize?.positionBits ?? DEFAULT_POSITION_BITS;
2512
+ if (requestedBits < 8) snapPositions(document.getRoot(), requestedBits);
2513
+ await quantize({
2514
+ quantizeColor: 8,
2515
+ quantizeNormal: options.quantize?.normalBits ?? DEFAULT_NORMAL_BITS,
2516
+ quantizePosition: Math.max(requestedBits, 8),
2517
+ quantizeTexcoord: options.quantize?.uvBits ?? DEFAULT_UV_BITS,
2518
+ pattern: /^(?!JOINTS|WEIGHTS)/u,
2519
+ normalizeWeights: false
2520
+ })(document);
2521
+ }
2522
+ const sourceTextures = textureBindings(document.getRoot());
2523
+ const embeddedTextures = textureOptions === void 0 ? void 0 : await compressEmbeddedTextures(document, logicalPath, textureOptions);
2524
+ if (enabled.meshopt) {
2525
+ document.createExtension(EXTMeshoptCompression).setRequired(true).setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
2526
+ }
2527
+ const narrowed = jointComponentSizes(document.getRoot());
2528
+ const narrowedSemantics = [];
2529
+ if (jointFloor.joints > narrowed.joints) {
2530
+ narrowedSemantics.push(
2531
+ `JOINTS (${String(jointFloor.joints)} -> ${String(narrowed.joints)} bytes)`
2532
+ );
2533
+ }
2534
+ if (jointFloor.weights > narrowed.weights) {
2535
+ narrowedSemantics.push(
2536
+ `WEIGHTS (${String(jointFloor.weights)} -> ${String(narrowed.weights)} bytes)`
2537
+ );
2538
+ }
2539
+ if (narrowedSemantics.length > 0) {
2540
+ throw new Error(
2541
+ `TN_ASSETS_MODEL_JOINT_QUANTIZED: '${logicalPath}' narrowed joint data storage for ${narrowedSemantics.join(", ")} per component; joint data is never quantized below the source declaration.`
2542
+ );
2543
+ }
2544
+ const { buffer, extensions } = await writeDocument(document, logicalPath);
2545
+ const verified = (await readDocument2(buffer, logicalPath)).getRoot();
2546
+ const output = reachableStats(verified);
2547
+ if (options.simplify === void 0) {
2548
+ assertNoDrift(source, output, logicalPath);
2549
+ } else {
2550
+ assertSimplifiedWithinBounds(source, output, options.simplify.ratio, logicalPath);
2551
+ }
2552
+ assertNoTextureDrift(sourceTextures, textureBindings(verified), logicalPath);
2553
+ const entry = {
2554
+ ...embeddedTextures === void 0 ? {} : { embeddedTextures },
2555
+ extensions: [...extensions].sort(),
2556
+ ...options.simplify === void 0 ? {} : {
2557
+ simplify: {
2558
+ achievedRatio: source.triangles === 0 ? 1 : output.triangles / source.triangles,
2559
+ error: options.simplify.error ?? DEFAULT_SIMPLIFY_ERROR,
2560
+ requestedRatio: options.simplify.ratio,
2561
+ trianglesAfter: output.triangles,
2562
+ trianglesBefore: source.triangles
2563
+ }
2564
+ },
2565
+ triangles: output.triangles,
2566
+ vertices: output.vertices,
2567
+ ...virtual === void 0 ? {} : { virtual }
2568
+ };
2569
+ return { buffer, entry: { ...entry } };
2570
+ }
2571
+ };
2572
+ }
2573
+
2574
+ // src/report.ts
2575
+ function formatTextureSizes(rows) {
2576
+ if (rows.length === 0) return [];
2577
+ const lines = rows.map(
2578
+ (row) => `texture ${row.logicalPath}${row.format === void 0 ? "" : ` (${row.format})`}: ${row.before} -> ${row.after} bytes ${deltaLabel(row.before, row.after)}`
2579
+ );
2580
+ const before = rows.reduce((total, row) => total + row.before, 0);
2581
+ const after = rows.reduce((total, row) => total + row.after, 0);
2582
+ lines.push(`textures total: ${before} -> ${after} bytes ${deltaLabel(before, after)}`);
2583
+ return lines;
2584
+ }
2585
+ function simplifyLine(row) {
2586
+ const simplify2 = row.simplify;
2587
+ if (simplify2 === void 0) return [];
2588
+ const kept = simplify2.achievedRatio * 100;
2589
+ const requested = simplify2.requestedRatio * 100;
2590
+ const short = simplify2.achievedRatio > simplify2.requestedRatio * 1.01 ? ` \u2014 the error tolerance ${simplify2.error} stopped it short` : "";
2591
+ return [
2592
+ `simplified ${row.logicalPath}: ${simplify2.trianglesBefore} -> ${simplify2.trianglesAfter} triangles (${kept.toFixed(1)}% kept, requested ${requested.toFixed(1)}%)${short}`
2593
+ ];
2594
+ }
2595
+ function virtualLine(row) {
2596
+ const virtual = row.virtual;
2597
+ if (virtual === void 0) return [];
2598
+ if (virtual.primitives === 0)
2599
+ return [
2600
+ `virtual ${row.logicalPath}: no primitive was dense enough to cluster (${virtual.skipped} skipped)`
2601
+ ];
2602
+ const warning = virtual.stopReason === "cap" ? " \u2014 a DAG hit the level cap and is unfinished" : "";
2603
+ return [
2604
+ `virtual ${row.logicalPath}: ${virtual.clusters} cluster(s) over ${virtual.levels} level(s) on ${virtual.primitives} primitive(s), ${virtual.skipped} skipped, ${virtual.payloadBytes} payload bytes, bake ${virtual.bakeSeconds.toFixed(1)} s, stopped at ${virtual.stopReason}${warning}`
2605
+ ];
2606
+ }
2607
+ function extensionLabel(row) {
2608
+ const extensions = row.extensions ?? [];
2609
+ return extensions.length === 0 ? "" : ` (${extensions.join(", ")})`;
2610
+ }
2611
+ function formatModelSizes(rows) {
2612
+ if (rows.length === 0) return [];
2613
+ const lines = rows.flatMap((row) => {
2614
+ const geometry = row.triangles === void 0 ? "" : `, ${String(row.triangles)} triangle(s)`;
2615
+ const model = `model ${row.logicalPath}${extensionLabel(row)}: ${row.before} -> ${row.after} bytes ${deltaLabel(row.before, row.after)}${geometry}`;
2616
+ const embedded = row.embeddedTextures;
2617
+ const images = embedded === void 0 ? [] : [
2618
+ `embedded textures ${row.logicalPath}: ${embedded.count} image(s), ${embedded.bytesBefore} -> ${embedded.bytesAfter} bytes ${deltaLabel(embedded.bytesBefore, embedded.bytesAfter)}, GPU ${embedded.gpuBytesBefore} -> ${embedded.gpuBytesAfter} bytes ${deltaLabel(embedded.gpuBytesBefore, embedded.gpuBytesAfter)}, ${embedded.resized} resized`
2619
+ ];
2620
+ const reduced = [...simplifyLine(row), ...virtualLine(row)];
2621
+ if (row.lightmap === void 0) return [model, ...reduced, ...images];
2622
+ const map = row.lightmap;
2623
+ return [
2624
+ model,
2625
+ ...reduced,
2626
+ ...images,
2627
+ `lightmap ${row.logicalPath}: atlas ${map.atlasWidth}x${map.atlasHeight}, ${map.validTexels} valid + ${map.dilatedTexels} dilated texels, ${map.occludedTexels} occluded, ${map.bytesBefore} -> ${map.bytesAfter} bytes ${deltaLabel(map.bytesBefore, map.bytesAfter)}, bake ${map.bakeMs.toFixed(1)} ms`
2628
+ ];
2629
+ });
2630
+ const before = rows.reduce((total, row) => total + row.before, 0);
2631
+ const after = rows.reduce((total, row) => total + row.after, 0);
2632
+ lines.push(`models total: ${before} -> ${after} bytes ${deltaLabel(before, after)}`);
2633
+ return lines;
2634
+ }
2635
+ function deltaLabel(before, after) {
2636
+ if (before <= 0) return `(${after} bytes)`;
2637
+ const percent = ((1 - after / before) * 100).toFixed(1);
2638
+ return `(-${percent}%)`;
2639
+ }
2640
+
2641
+ // src/compile.ts
2642
+ var MANIFEST_NAME = "assets.manifest.json";
2643
+ var DEFAULT_SOURCE = "assets";
2644
+ var DEFAULT_OUTPUT = "public";
2645
+ var BASIS_DIRECTORY = "basis";
2646
+ var BASISU_EXTENSION = "KHR_texture_basisu";
2647
+ var PIPELINE_VERSION = 7;
2648
+ var KIND_BY_EXTENSION = {
2649
+ glb: "model",
2650
+ gltf: "model",
2651
+ jpeg: "texture",
2652
+ jpg: "texture",
2653
+ mp3: "audio",
2654
+ ogg: "audio",
2655
+ png: "texture",
2656
+ wav: "audio",
2657
+ webp: "texture"
2658
+ };
2659
+ var CODECS = ["etc1s", "none", "uastc"];
2660
+ function isRecord(value) {
2661
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2662
+ }
2663
+ function simplifyRow(value) {
2664
+ if (!isRecord(value)) return void 0;
2665
+ const keys = [
2666
+ "achievedRatio",
2667
+ "error",
2668
+ "requestedRatio",
2669
+ "trianglesAfter",
2670
+ "trianglesBefore"
2671
+ ];
2672
+ if (keys.some((key) => typeof value[key] !== "number")) return void 0;
2673
+ return {
2674
+ achievedRatio: value.achievedRatio,
2675
+ error: value.error,
2676
+ requestedRatio: value.requestedRatio,
2677
+ trianglesAfter: value.trianglesAfter,
2678
+ trianglesBefore: value.trianglesBefore
2679
+ };
2680
+ }
2681
+ function embeddedTextureRow(value) {
2682
+ if (!isRecord(value)) return void 0;
2683
+ const numbers = [
2684
+ "bytesAfter",
2685
+ "bytesBefore",
2686
+ "count",
2687
+ "gpuBytesAfter",
2688
+ "gpuBytesBefore",
2689
+ "resized"
2690
+ ];
2691
+ if (numbers.some((key) => typeof value[key] !== "number")) return void 0;
2692
+ const formats = isRecord(value.formats) ? Object.fromEntries(
2693
+ Object.entries(value.formats).filter(([, codec]) => typeof codec === "string")
2694
+ ) : void 0;
2695
+ return {
2696
+ bytesAfter: value.bytesAfter,
2697
+ bytesBefore: value.bytesBefore,
2698
+ count: value.count,
2699
+ ...formats === void 0 ? {} : { formats },
2700
+ gpuBytesAfter: value.gpuBytesAfter,
2701
+ gpuBytesBefore: value.gpuBytesBefore,
2702
+ resized: value.resized
2703
+ };
2704
+ }
2705
+ function nonEmptyString(value, label) {
2706
+ if (typeof value !== "string" || value.trim().length === 0) {
2707
+ throw new Error(`TN_ASSETS_CONFIG_INVALID: ${label} must be a non-empty string.`);
2708
+ }
2709
+ return value;
2710
+ }
2711
+ function messageOf3(error) {
2712
+ return error instanceof Error ? error.message : String(error);
2713
+ }
2714
+ var TARGET_KEYS = ["maxMaterials", "maxTriangles", "maxTextureDimension"];
2715
+ function positiveTarget(value, label) {
2716
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
2717
+ throw new Error(
2718
+ `TN_ASSETS_CONFIG_INVALID: assets.targets.${label} must be a positive integer.`
2719
+ );
2720
+ }
2721
+ return value;
2722
+ }
2723
+ function validateTargets(raw) {
2724
+ if (!isRecord(raw)) {
2725
+ throw new Error("TN_ASSETS_CONFIG_INVALID: assets.targets must be an object.");
2726
+ }
2727
+ for (const key of Object.keys(raw)) {
2728
+ if (!TARGET_KEYS.includes(key)) {
2729
+ throw new Error(`TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.targets.${key} is not recognised.`);
2730
+ }
2731
+ }
2732
+ return {
2733
+ ...raw.maxMaterials === void 0 ? {} : { maxMaterials: positiveTarget(raw.maxMaterials, "maxMaterials") },
2734
+ ...raw.maxTriangles === void 0 ? {} : { maxTriangles: positiveTarget(raw.maxTriangles, "maxTriangles") },
2735
+ ...raw.maxTextureDimension === void 0 ? {} : { maxTextureDimension: positiveTarget(raw.maxTextureDimension, "maxTextureDimension") }
2736
+ };
2737
+ }
2738
+ function textureQuality(value, label) {
2739
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > 255) {
2740
+ throw new Error(`TN_ASSETS_CONFIG_INVALID: ${label} must be an integer between 1 and 255.`);
2741
+ }
2742
+ return value;
2743
+ }
2744
+ function validateTextureOverrides(raw) {
2745
+ if (!Array.isArray(raw)) {
2746
+ throw new Error("TN_ASSETS_CONFIG_INVALID: assets.textures.overrides must be an array.");
2747
+ }
2748
+ return raw.map((item, index) => {
2749
+ if (!isRecord(item)) {
2750
+ throw new Error(
2751
+ `TN_ASSETS_CONFIG_INVALID: assets.textures.overrides[${String(index)}] must be an object.`
2752
+ );
2753
+ }
2754
+ for (const key of Object.keys(item)) {
2755
+ if (key !== "glob" && key !== "codec" && key !== "quality") {
2756
+ throw new Error(
2757
+ `TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.textures.overrides.${key} is not recognised.`
2758
+ );
2759
+ }
2760
+ }
2761
+ const glob = nonEmptyString(item.glob, `assets.textures.overrides[${String(index)}].glob`);
2762
+ if (!CODECS.includes(item.codec)) {
2763
+ throw new Error(
2764
+ `TN_ASSETS_CONFIG_INVALID: assets.textures.overrides[${String(index)}].codec must be one of ${CODECS.join(", ")}; received '${String(item.codec)}'.`
2765
+ );
2766
+ }
2767
+ return {
2768
+ codec: item.codec,
2769
+ glob,
2770
+ ...item.quality === void 0 ? {} : {
2771
+ quality: textureQuality(
2772
+ item.quality,
2773
+ `assets.textures.overrides[${String(index)}].quality`
2774
+ )
2775
+ }
2776
+ };
2777
+ });
2778
+ }
2779
+ function parseTexturesConfig(raw) {
2780
+ if (raw === void 0) return {};
2781
+ if (raw === "none") return void 0;
2782
+ if (!isRecord(raw)) {
2783
+ throw new Error('TN_ASSETS_CONFIG_INVALID: assets.textures must be "none" or an object.');
2784
+ }
2785
+ for (const key of Object.keys(raw)) {
2786
+ if (key !== "quality" && key !== "overrides") {
2787
+ throw new Error(`TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.textures.${key} is not recognised.`);
2788
+ }
2789
+ }
2790
+ return {
2791
+ ...raw.quality === void 0 ? {} : { quality: textureQuality(raw.quality, "assets.textures.quality") },
2792
+ ...raw.overrides === void 0 ? {} : { overrides: validateTextureOverrides(raw.overrides) }
2793
+ };
2794
+ }
2795
+ function parseModelsConfig(raw) {
2796
+ if (raw === void 0) return {};
2797
+ if (raw === "none") return void 0;
2798
+ if (!isRecord(raw)) {
2799
+ throw new Error('TN_ASSETS_CONFIG_INVALID: assets.models must be "none" or an object.');
2800
+ }
2801
+ const allowed = ["lightmap", "passes", "quantize", "simplify", "textures", "virtual"];
2802
+ for (const key of Object.keys(raw)) {
2803
+ if (!allowed.includes(key)) {
2804
+ throw new Error(`TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.models.${key} is not recognised.`);
2805
+ }
2806
+ }
2807
+ const passes = raw.passes === void 0 ? {} : parseModelPasses(raw.passes);
2808
+ const quantize2 = raw.quantize === void 0 ? {} : parseModelQuantize(raw.quantize);
2809
+ const lightmap = raw.lightmap === void 0 ? void 0 : parseLightmap(raw.lightmap);
2810
+ const simplify2 = raw.simplify === void 0 ? void 0 : parseModelSimplify(raw.simplify);
2811
+ const textures = raw.textures === void 0 ? void 0 : parseModelTextures(raw.textures);
2812
+ const virtual = raw.virtual === void 0 ? void 0 : parseModelVirtual(raw.virtual);
2813
+ return {
2814
+ ...lightmap === void 0 ? {} : { lightmap },
2815
+ ...Object.keys(passes).length === 0 ? {} : { passes },
2816
+ ...Object.keys(quantize2).length === 0 ? {} : { quantize: quantize2 },
2817
+ ...simplify2 === void 0 ? {} : { simplify: simplify2 },
2818
+ ...textures === void 0 ? {} : { textures },
2819
+ ...virtual === void 0 ? {} : { virtual }
2820
+ };
2821
+ }
2822
+ var MODEL_TEXTURE_KEYS = ["maxSize", "overrides", "quality"];
2823
+ function positiveInteger2(value, label) {
2824
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
2825
+ throw new Error(
2826
+ `TN_ASSETS_CONFIG_INVALID: assets.models.textures.${label} must be a positive integer.`
2827
+ );
2828
+ }
2829
+ return value;
2830
+ }
2831
+ function parseModelTextures(raw) {
2832
+ if (raw === "none") return "none";
2833
+ if (!isRecord(raw)) {
2834
+ throw new Error(
2835
+ 'TN_ASSETS_CONFIG_INVALID: assets.models.textures must be "none" or an object.'
2836
+ );
2837
+ }
2838
+ for (const key of Object.keys(raw)) {
2839
+ if (!MODEL_TEXTURE_KEYS.includes(key)) {
2840
+ throw new Error(
2841
+ `TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.models.textures.${key} is not recognised.`
2842
+ );
2843
+ }
2844
+ }
2845
+ return {
2846
+ ...raw.maxSize === void 0 ? {} : { maxSize: positiveInteger2(raw.maxSize, "maxSize") },
2847
+ ...raw.overrides === void 0 ? {} : { overrides: validateModelTextureOverrides(raw.overrides) },
2848
+ ...raw.quality === void 0 ? {} : { quality: textureQuality(raw.quality, "assets.models.textures.quality") }
2849
+ };
2850
+ }
2851
+ function validateModelTextureOverrides(raw) {
2852
+ if (!Array.isArray(raw)) {
2853
+ throw new Error("TN_ASSETS_CONFIG_INVALID: assets.models.textures.overrides must be an array.");
2854
+ }
2855
+ return raw.map((item, index) => {
2856
+ const label = `assets.models.textures.overrides[${String(index)}]`;
2857
+ if (!isRecord(item)) throw new Error(`TN_ASSETS_CONFIG_INVALID: ${label} must be an object.`);
2858
+ for (const key of Object.keys(item)) {
2859
+ if (key !== "slot" && key !== "codec") {
2860
+ throw new Error(
2861
+ `TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.models.textures.overrides.${key} is not recognised.`
2862
+ );
2863
+ }
2864
+ }
2865
+ if (!CODECS.includes(item.codec)) {
2866
+ throw new Error(
2867
+ `TN_ASSETS_CONFIG_INVALID: ${label}.codec must be one of ${CODECS.join(", ")}; received '${String(item.codec)}'.`
2868
+ );
2869
+ }
2870
+ return {
2871
+ codec: item.codec,
2872
+ slot: nonEmptyString(item.slot, `${label}.slot`)
2873
+ };
2874
+ });
2875
+ }
2876
+ function parseModelSimplify(raw) {
2877
+ if (!isRecord(raw)) {
2878
+ throw new Error("TN_ASSETS_CONFIG_INVALID: assets.models.simplify must be an object.");
2879
+ }
2880
+ for (const key of Object.keys(raw)) {
2881
+ if (key !== "error" && key !== "ratio") {
2882
+ throw new Error(
2883
+ `TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.models.simplify.${key} is not recognised.`
2884
+ );
2885
+ }
2886
+ }
2887
+ if (typeof raw.ratio !== "number" || !(raw.ratio > 0) || raw.ratio > 1) {
2888
+ throw new Error(
2889
+ "TN_ASSETS_CONFIG_INVALID: assets.models.simplify.ratio must be a number greater than 0 and at most 1."
2890
+ );
2891
+ }
2892
+ if (raw.error !== void 0 && (typeof raw.error !== "number" || raw.error < 0)) {
2893
+ throw new Error(
2894
+ "TN_ASSETS_CONFIG_INVALID: assets.models.simplify.error must be a non-negative number."
2895
+ );
2896
+ }
2897
+ return {
2898
+ ratio: raw.ratio,
2899
+ ...raw.error === void 0 ? {} : { error: raw.error }
2900
+ };
2901
+ }
2902
+ function parseModelVirtual(raw) {
2903
+ if (raw === "none") return "none";
2904
+ if (!isRecord(raw)) {
2905
+ throw new Error('TN_ASSETS_CONFIG_INVALID: assets.models.virtual must be "none" or an object.');
2906
+ }
2907
+ const counts = ["groupSize", "maxTriangles", "minSourceTriangles", "minTriangles"];
2908
+ for (const key of Object.keys(raw)) {
2909
+ if (key !== "simplifyRatio" && !counts.includes(key)) {
2910
+ throw new Error(
2911
+ `TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.models.virtual.${key} is not recognised.`
2912
+ );
2913
+ }
2914
+ }
2915
+ const parsed = {};
2916
+ for (const key of counts) {
2917
+ const value = raw[key];
2918
+ if (value === void 0) continue;
2919
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
2920
+ throw new Error(
2921
+ `TN_ASSETS_CONFIG_INVALID: assets.models.virtual.${key} must be a positive integer.`
2922
+ );
2923
+ }
2924
+ parsed[key] = value;
2925
+ }
2926
+ if (raw.simplifyRatio !== void 0) {
2927
+ if (typeof raw.simplifyRatio !== "number" || !(raw.simplifyRatio > 0) || !(raw.simplifyRatio < 1))
2928
+ throw new Error(
2929
+ "TN_ASSETS_CONFIG_INVALID: assets.models.virtual.simplifyRatio must be a number between 0 and 1, exclusive."
2930
+ );
2931
+ parsed.simplifyRatio = raw.simplifyRatio;
2932
+ }
2933
+ return parsed;
2934
+ }
2935
+ function parseLightmap(raw) {
2936
+ if (!isRecord(raw)) {
2937
+ throw new Error("TN_ASSETS_CONFIG_INVALID: assets.models.lightmap must be an object.");
2938
+ }
2939
+ for (const key of Object.keys(raw)) {
2940
+ if (key !== "atlasSize" && key !== "padding") {
2941
+ throw new Error(
2942
+ `TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.models.lightmap.${key} is not recognised.`
2943
+ );
2944
+ }
2945
+ }
2946
+ if (!Number.isSafeInteger(raw.atlasSize) || raw.atlasSize <= 0) {
2947
+ throw new Error(
2948
+ "TN_ASSETS_CONFIG_INVALID: assets.models.lightmap.atlasSize must be a positive integer."
2949
+ );
2950
+ }
2951
+ if (!Number.isSafeInteger(raw.padding) || raw.padding <= 0) {
2952
+ throw new Error(
2953
+ "TN_ASSETS_CONFIG_INVALID: assets.models.lightmap.padding must be a positive integer."
2954
+ );
2955
+ }
2956
+ return { atlasSize: raw.atlasSize, padding: raw.padding };
2957
+ }
2958
+ var MODEL_PASS_KEYS = ["dedup", "meshopt", "prune", "quantize", "reorder"];
2959
+ function parseModelPasses(raw) {
2960
+ if (!isRecord(raw)) {
2961
+ throw new Error("TN_ASSETS_CONFIG_INVALID: assets.models.passes must be an object.");
2962
+ }
2963
+ for (const key of Object.keys(raw)) {
2964
+ if (!MODEL_PASS_KEYS.includes(key)) {
2965
+ throw new Error(
2966
+ `TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.models.passes.${key} is not recognised.`
2967
+ );
2968
+ }
2969
+ if (typeof raw[key] !== "boolean") {
2970
+ throw new Error(`TN_ASSETS_CONFIG_INVALID: assets.models.passes.${key} must be a boolean.`);
2971
+ }
2972
+ }
2973
+ return raw;
2974
+ }
2975
+ var MODEL_QUANTIZE_KEYS = ["normalBits", "positionBits", "uvBits"];
2976
+ function parseModelQuantize(raw) {
2977
+ if (!isRecord(raw)) {
2978
+ throw new Error("TN_ASSETS_CONFIG_INVALID: assets.models.quantize must be an object.");
2979
+ }
2980
+ for (const key of Object.keys(raw)) {
2981
+ if (!MODEL_QUANTIZE_KEYS.includes(key)) {
2982
+ throw new Error(
2983
+ `TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.models.quantize.${key} is not recognised.`
2984
+ );
2985
+ }
2986
+ if (typeof raw[key] !== "number" || !Number.isSafeInteger(raw[key]) || raw[key] < 1 || raw[key] > 16) {
2987
+ throw new Error(
2988
+ `TN_ASSETS_CONFIG_INVALID: assets.models.quantize.${key} must be an integer between 1 and 16 bits.`
2989
+ );
2990
+ }
2991
+ }
2992
+ return raw;
2993
+ }
2994
+ function resolveLayout(cwd, options) {
2995
+ const config = options.config ?? {};
2996
+ if (!isRecord(config)) {
2997
+ throw new Error("TN_ASSETS_CONFIG_INVALID: assets must be an object when declared.");
2998
+ }
2999
+ for (const key of Object.keys(config)) {
3000
+ if (key !== "source" && key !== "output" && key !== "targets" && key !== "textures" && key !== "models") {
3001
+ throw new Error(`TN_ASSETS_CONFIG_UNKNOWN_KEY: assets.${key} is not recognised.`);
3002
+ }
3003
+ }
3004
+ const source = options.source ?? config.source ?? DEFAULT_SOURCE;
3005
+ const output = options.output ?? config.output ?? DEFAULT_OUTPUT;
3006
+ const sourceRoot = path.resolve(cwd, nonEmptyString(source, "assets.source"));
3007
+ const outputRoot = path.resolve(cwd, nonEmptyString(output, "assets.output"));
3008
+ const nested = sourceRoot === outputRoot || sourceRoot.startsWith(`${outputRoot}${path.sep}`) || outputRoot.startsWith(`${sourceRoot}${path.sep}`);
3009
+ if (nested) {
3010
+ throw new Error(
3011
+ `TN_ASSETS_OVERLAP: source '${sourceRoot}' and output '${outputRoot}' must be disjoint directories.`
3012
+ );
3013
+ }
3014
+ const textures = parseTexturesConfig(config.textures);
3015
+ const models = parseModelsConfig(config.models);
3016
+ const lightmap = models?.lightmap;
3017
+ const builtinPasses = options.passes === void 0 ? [
3018
+ ...textures !== void 0 ? [texturePass(textures)] : [],
3019
+ ...lightmap !== void 0 ? [lightmapPass(lightmap)] : [],
3020
+ ...models !== void 0 ? [modelPass({ ...models, preserveLightmapUv: lightmap !== void 0 })] : []
3021
+ ] : [];
3022
+ return {
3023
+ outputRoot,
3024
+ passes: [...builtinPasses, ...options.passes ?? []],
3025
+ sourceRoot,
3026
+ targets: config.targets === void 0 ? {} : validateTargets(config.targets),
3027
+ texturesActive: textures !== void 0 && options.passes === void 0
3028
+ };
3029
+ }
3030
+ async function scanDirectory(sourceRoot, directory) {
3031
+ const entries = await readdir(path.join(sourceRoot, directory), { withFileTypes: true });
3032
+ entries.sort((left, right) => left.name < right.name ? -1 : 1);
3033
+ const files = [];
3034
+ const subdirectories = [];
3035
+ for (const entry of entries) {
3036
+ if (entry.name.startsWith(".")) continue;
3037
+ const logical = directory === "" ? entry.name : `${directory}/${entry.name}`;
3038
+ if (entry.isDirectory()) {
3039
+ subdirectories.push(logical);
3040
+ continue;
3041
+ }
3042
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
3043
+ throw new Error(
3044
+ `TN_ASSETS_INPUT_UNREADABLE: '${logical}' under '${sourceRoot}' is neither a file nor a directory.`
3045
+ );
3046
+ }
3047
+ files.push(logical);
3048
+ }
3049
+ return { files, subdirectories };
3050
+ }
3051
+ async function walkSources(sourceRoot) {
3052
+ const logicalPaths = [];
3053
+ const seenFiles = /* @__PURE__ */ new Set();
3054
+ const visitedDirectories = /* @__PURE__ */ new Set();
3055
+ const queue = [""];
3056
+ while (queue.length > 0) {
3057
+ const directory = queue.shift();
3058
+ if (directory === void 0) break;
3059
+ const canonical = await realpath(path.join(sourceRoot, directory));
3060
+ if (visitedDirectories.has(canonical)) {
3061
+ throw new Error(
3062
+ `TN_ASSETS_DUPLICATE_LOGICAL_PATH: '${directory}' resolves to an already-visited directory; duplicate logical path under '${sourceRoot}'.`
3063
+ );
3064
+ }
3065
+ visitedDirectories.add(canonical);
3066
+ const scanned = await scanDirectory(sourceRoot, directory);
3067
+ queue.push(...scanned.subdirectories);
3068
+ for (const logical of scanned.files) {
3069
+ if (seenFiles.has(logical)) {
3070
+ throw new Error(
3071
+ `TN_ASSETS_DUPLICATE_LOGICAL_PATH: '${logical}' was listed twice; duplicate logical path under '${sourceRoot}'.`
3072
+ );
3073
+ }
3074
+ seenFiles.add(logical);
3075
+ logicalPaths.push(logical);
3076
+ }
3077
+ }
3078
+ return logicalPaths.sort();
3079
+ }
3080
+ function classify(logicalPath) {
3081
+ const extension = path.extname(logicalPath).slice(1).toLowerCase();
3082
+ return KIND_BY_EXTENSION[extension] ?? "other";
3083
+ }
3084
+ function outputNameFor(logicalPath, digest, extension) {
3085
+ const sourceExtension = path.extname(logicalPath);
3086
+ const base = path.basename(logicalPath, sourceExtension);
3087
+ const directory = path.dirname(logicalPath);
3088
+ const name = `${base}.${digest}${extension ?? sourceExtension.toLowerCase()}`;
3089
+ return directory === "." ? name : `${directory}/${name}`;
3090
+ }
3091
+ function sameTargets(left, right) {
3092
+ if (left === void 0 || right === void 0) return left === right;
3093
+ return left.length === right.length && left.every((item, index) => item === right[index]);
3094
+ }
3095
+ function sameEntry(existing, entry) {
3096
+ return existing.output === entry.output && existing.kind === entry.kind && JSON.stringify(existing.lightmapAtlas) === JSON.stringify(entry.lightmapAtlas) && JSON.stringify(existing.lightmaps) === JSON.stringify(entry.lightmaps) && JSON.stringify(existing.embeddedTextures) === JSON.stringify(entry.embeddedTextures) && JSON.stringify(existing.simplify) === JSON.stringify(entry.simplify) && existing.bytes === entry.bytes && existing.bytesBefore === entry.bytesBefore && existing.bytesAfter === entry.bytesAfter && existing.format === entry.format && sameTargets(existing.transcodeTargets, entry.transcodeTargets) && sameTargets(existing.extensions, entry.extensions) && existing.triangles === entry.triangles && existing.vertices === entry.vertices && existing.passes.length === entry.passes.length && existing.passes.every((name, index) => name === entry.passes[index]);
3097
+ }
3098
+ function resolveAuxiliaryOutputs(logical, outputs) {
3099
+ const sourceExtension = path.extname(logical);
3100
+ const stem = logical.slice(0, -sourceExtension.length);
3101
+ return outputs.map((auxiliary) => {
3102
+ const digest = createHash("sha256").update(auxiliary.buffer).digest("hex").slice(0, 8);
3103
+ const output = outputNameFor(
3104
+ `${stem}.${auxiliary.role}${auxiliary.extension}`,
3105
+ digest,
3106
+ auxiliary.extension
3107
+ );
3108
+ return {
3109
+ buffer: auxiliary.buffer,
3110
+ manifestField: auxiliary.manifestField,
3111
+ metadata: {
3112
+ ...auxiliary.metadata ?? {},
3113
+ bytes: auxiliary.buffer.length,
3114
+ output
3115
+ },
3116
+ output
3117
+ };
3118
+ });
3119
+ }
3120
+ function auxiliaryManifestFields(outputs) {
3121
+ const fields = {};
3122
+ for (const output of outputs) {
3123
+ const field = fields[output.manifestField] ?? [];
3124
+ field.push(output.metadata);
3125
+ fields[output.manifestField] = field;
3126
+ }
3127
+ return fields;
3128
+ }
3129
+ async function readExistingManifest(manifestPath) {
3130
+ let raw;
3131
+ try {
3132
+ raw = await readFile(manifestPath, "utf8");
3133
+ } catch {
3134
+ return { entries: {}, raw: void 0 };
3135
+ }
3136
+ let parsed;
3137
+ try {
3138
+ parsed = JSON.parse(raw);
3139
+ } catch (error) {
3140
+ throw new Error(
3141
+ `TN_ASSETS_MANIFEST_INVALID: '${manifestPath}' is not valid JSON: ${messageOf3(error)}`
3142
+ );
3143
+ }
3144
+ if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.entries)) {
3145
+ throw new Error(`TN_ASSETS_MANIFEST_INVALID: '${manifestPath}' must hold version 1 entries.`);
3146
+ }
3147
+ return { entries: parsed.entries, raw };
3148
+ }
3149
+ async function hasSourceDirectory(sourceRoot) {
3150
+ let info;
3151
+ try {
3152
+ info = await stat(sourceRoot);
3153
+ } catch {
3154
+ return false;
3155
+ }
3156
+ if (!info.isDirectory()) {
3157
+ throw new Error(`TN_ASSETS_SOURCE_INVALID: asset source '${sourceRoot}' is not a directory.`);
3158
+ }
3159
+ return true;
3160
+ }
3161
+ async function outputExists(outputAbsolute) {
3162
+ let info;
3163
+ try {
3164
+ info = await stat(outputAbsolute);
3165
+ } catch {
3166
+ return false;
3167
+ }
3168
+ return info.isFile();
3169
+ }
3170
+ async function readInput(sourceRoot, logical) {
3171
+ try {
3172
+ return await readFile(path.join(sourceRoot, logical));
3173
+ } catch (error) {
3174
+ throw new Error(
3175
+ `TN_ASSETS_INPUT_UNREADABLE: could not read '${logical}' under '${sourceRoot}': ${messageOf3(error)}`
3176
+ );
3177
+ }
3178
+ }
3179
+ async function applyPasses(passes, input, logical) {
3180
+ let buffer = input;
3181
+ const auxiliaryOutputs = [];
3182
+ let entry;
3183
+ let extension;
3184
+ for (const pass of passes) {
3185
+ let result;
3186
+ try {
3187
+ result = await pass.apply(buffer, logical);
3188
+ } catch (error) {
3189
+ throw new Error(
3190
+ `TN_ASSETS_PASS_FAILED: pass '${pass.name}' failed for '${logical}': ${messageOf3(error)}`
3191
+ );
3192
+ }
3193
+ if (Buffer.isBuffer(result)) {
3194
+ buffer = result;
3195
+ continue;
3196
+ }
3197
+ buffer = result.buffer;
3198
+ if (result.auxiliaryOutputs !== void 0) auxiliaryOutputs.push(...result.auxiliaryOutputs);
3199
+ if (result.entry !== void 0) entry = { ...entry ?? {}, ...result.entry };
3200
+ if (result.outputExtension !== void 0) extension = result.outputExtension;
3201
+ }
3202
+ return { auxiliaryOutputs, buffer, entry, extension };
3203
+ }
3204
+ async function writeOutput(outputRoot, entry, buffer) {
3205
+ const outputAbsolute = path.join(outputRoot, entry.output);
3206
+ await mkdir(path.dirname(outputAbsolute), { recursive: true });
3207
+ await writeFile(outputAbsolute, buffer);
3208
+ }
3209
+ async function writeManifest(manifestPath, outputRoot, raw, entries) {
3210
+ const manifest = { version: 1, entries: {} };
3211
+ for (const logical of Object.keys(entries).sort()) {
3212
+ const entry = entries[logical];
3213
+ if (entry !== void 0) manifest.entries[logical] = entry;
3214
+ }
3215
+ const serialized = `${JSON.stringify(manifest, null, 2)}
3216
+ `;
3217
+ if (serialized === raw) return;
3218
+ await mkdir(outputRoot, { recursive: true });
3219
+ await writeFile(manifestPath, serialized, "utf8");
3220
+ }
3221
+ function resolveBasisTranscoder(cwd) {
3222
+ try {
3223
+ const javascriptPath = createRequire(path.join(cwd, "package.json")).resolve(
3224
+ "three/examples/jsm/libs/basis/basis_transcoder.js"
3225
+ );
3226
+ return { javascriptPath, wasmPath: javascriptPath.replace(/\.js$/u, ".wasm") };
3227
+ } catch (error) {
3228
+ throw new Error(
3229
+ `TN_ASSETS_TRANSCODER_MISSING: could not resolve three's Basis transcoder from '${cwd}': ${messageOf3(error)}. The runtime KTX2 loader needs it copied next to the compiled assets.`
3230
+ );
3231
+ }
3232
+ }
3233
+ async function copyBasisTranscoder(outputRoot, transcoder) {
3234
+ const target = path.join(outputRoot, BASIS_DIRECTORY);
3235
+ try {
3236
+ await mkdir(target, { recursive: true });
3237
+ await copyFile(transcoder.javascriptPath, path.join(target, "basis_transcoder.js"));
3238
+ await copyFile(transcoder.wasmPath, path.join(target, "basis_transcoder.wasm"));
3239
+ } catch (error) {
3240
+ throw new Error(
3241
+ `TN_ASSETS_TRANSCODER_MISSING: could not copy the Basis transcoder into '${target}': ${messageOf3(error)}`
3242
+ );
3243
+ }
3244
+ }
3245
+ async function compileAssets(options = {}) {
3246
+ const cwd = path.resolve(options.cwd ?? process.cwd());
3247
+ const layout = resolveLayout(cwd, options);
3248
+ if (!await hasSourceDirectory(layout.sourceRoot)) return { skipped: 0, written: 0 };
3249
+ const manifestPath = path.join(layout.outputRoot, MANIFEST_NAME);
3250
+ const previous = await readExistingManifest(manifestPath);
3251
+ const passNames = layout.passes.map((pass) => pass.name);
3252
+ const passCacheKeys = layout.passes.map((pass) => pass.cacheKey ?? null);
3253
+ const passConfiguration = JSON.stringify({
3254
+ ...passCacheKeys.some((key) => key !== null) ? { passCacheKeys } : {},
3255
+ pipelineVersion: PIPELINE_VERSION,
3256
+ passes: passNames,
3257
+ options: layout.passes.map((pass) => pass.configuration ?? null)
3258
+ });
3259
+ const entries = {};
3260
+ const healthInputs = [];
3261
+ const textureRows = [];
3262
+ const modelRows = [];
3263
+ let written = 0;
3264
+ let skipped = 0;
3265
+ let textureCount = 0;
3266
+ let compressedModelCount = 0;
3267
+ const logicals = await walkSources(layout.sourceRoot);
3268
+ if (logicals.length === 0) {
3269
+ if (previous.raw !== void 0) await rm(manifestPath, { force: true });
3270
+ const report2 = await runHealthReport([], layout.targets);
3271
+ return options.health === true ? { report: report2, skipped: 0, written: 0 } : { skipped: 0, written: 0 };
3272
+ }
3273
+ for (const logical of logicals) {
3274
+ const input = await readInput(layout.sourceRoot, logical);
3275
+ const applied = await applyPasses(layout.passes, input, logical);
3276
+ const auxiliaryOutputs = resolveAuxiliaryOutputs(logical, applied.auxiliaryOutputs);
3277
+ const auxiliaryFields = auxiliaryManifestFields(auxiliaryOutputs);
3278
+ const digest = createHash("sha256").update(input).update(passConfiguration, "utf8").digest("hex");
3279
+ const entry = {
3280
+ bytes: applied.buffer.length,
3281
+ kind: classify(logical),
3282
+ output: outputNameFor(logical, digest.slice(0, 8), applied.extension),
3283
+ passes: [...passNames],
3284
+ ...auxiliaryFields,
3285
+ ...applied.entry === void 0 ? {} : {
3286
+ bytesAfter: applied.buffer.length,
3287
+ bytesBefore: input.length,
3288
+ embeddedTextures: embeddedTextureRow(applied.entry.embeddedTextures),
3289
+ simplify: simplifyRow(applied.entry.simplify),
3290
+ extensions: Array.isArray(applied.entry.extensions) ? applied.entry.extensions : void 0,
3291
+ format: typeof applied.entry.format === "string" ? applied.entry.format : void 0,
3292
+ lightmapAtlas: isRecord(applied.entry.lightmapAtlas) ? applied.entry.lightmapAtlas : void 0,
3293
+ triangles: typeof applied.entry.triangles === "number" ? applied.entry.triangles : void 0,
3294
+ transcodeTargets: Array.isArray(applied.entry.transcodeTargets) ? applied.entry.transcodeTargets : void 0,
3295
+ vertices: typeof applied.entry.vertices === "number" ? applied.entry.vertices : void 0
3296
+ }
3297
+ };
3298
+ entries[logical] = entry;
3299
+ if (entry.extensions?.includes(BASISU_EXTENSION) === true) compressedModelCount += 1;
3300
+ healthInputs.push({ data: input, logicalPath: logical });
3301
+ if (entry.bytesBefore !== void 0) {
3302
+ if (entry.triangles !== void 0) {
3303
+ const lightmapOutput = auxiliaryOutputs.find(
3304
+ (output) => output.manifestField === "lightmaps"
3305
+ );
3306
+ const lightmapMetadata = lightmapOutput?.metadata;
3307
+ const lightmapAtlas = applied.entry?.lightmapAtlas;
3308
+ const lightmap = lightmapOutput !== void 0 && isRecord(lightmapMetadata) && isRecord(lightmapAtlas) && typeof applied.entry?.lightmapBakeMs === "number" ? {
3309
+ atlasHeight: Number(lightmapAtlas.height),
3310
+ atlasWidth: Number(lightmapAtlas.width),
3311
+ bakeMs: applied.entry.lightmapBakeMs,
3312
+ bytesAfter: lightmapOutput.buffer.length,
3313
+ bytesBefore: Number(lightmapMetadata.bytesBefore),
3314
+ dilatedTexels: Number(lightmapMetadata.dilatedTexels),
3315
+ occludedTexels: Number(lightmapMetadata.occludedTexels),
3316
+ validTexels: Number(lightmapMetadata.validTexels)
3317
+ } : void 0;
3318
+ modelRows.push({
3319
+ after: entry.bytes,
3320
+ before: entry.bytesBefore,
3321
+ ...entry.embeddedTextures === void 0 ? {} : { embeddedTextures: entry.embeddedTextures },
3322
+ ...entry.simplify === void 0 ? {} : { simplify: entry.simplify },
3323
+ extensions: entry.extensions,
3324
+ logicalPath: logical,
3325
+ ...lightmap === void 0 ? {} : { lightmap },
3326
+ triangles: entry.triangles
3327
+ });
3328
+ } else {
3329
+ textureRows.push({
3330
+ after: entry.bytes,
3331
+ before: entry.bytesBefore,
3332
+ format: entry.format,
3333
+ logicalPath: logical
3334
+ });
3335
+ textureCount += 1;
3336
+ }
3337
+ }
3338
+ const existing = previous.entries[logical];
3339
+ if (existing !== void 0 && sameEntry(existing, entry) && await outputExists(path.join(layout.outputRoot, entry.output)) && (await Promise.all(
3340
+ auxiliaryOutputs.map(
3341
+ (output) => outputExists(path.join(layout.outputRoot, output.output))
3342
+ )
3343
+ )).every(Boolean)) {
3344
+ skipped += 1;
3345
+ continue;
3346
+ }
3347
+ await writeOutput(layout.outputRoot, entry, applied.buffer);
3348
+ for (const output of auxiliaryOutputs) {
3349
+ await mkdir(path.dirname(path.join(layout.outputRoot, output.output)), { recursive: true });
3350
+ await writeFile(path.join(layout.outputRoot, output.output), output.buffer);
3351
+ }
3352
+ written += 1;
3353
+ }
3354
+ if (layout.texturesActive && textureCount > 0 || compressedModelCount > 0) {
3355
+ const basisJs = path.join(layout.outputRoot, BASIS_DIRECTORY, "basis_transcoder.js");
3356
+ if (written > 0 || !await outputExists(basisJs)) {
3357
+ await copyBasisTranscoder(
3358
+ layout.outputRoot,
3359
+ options.transcoder ?? resolveBasisTranscoder(cwd)
3360
+ );
3361
+ }
3362
+ }
3363
+ const report = await runHealthReport(healthInputs, layout.targets);
3364
+ for (const line of formatHealthReport(report)) console.log(line);
3365
+ for (const line of formatTextureSizes(textureRows)) console.log(line);
3366
+ for (const line of formatModelSizes(modelRows)) console.log(line);
3367
+ if (report.failed) {
3368
+ const failedAssets = report.findings.filter((finding) => finding.grade === "fail").map((finding) => finding.asset);
3369
+ throw new Error(
3370
+ `TN_ASSETS_HEALTH_FAILED: ${failedAssets.length} declared asset target(s) exceeded: ${[...new Set(failedAssets)].join(", ")}`
3371
+ );
3372
+ }
3373
+ await writeManifest(manifestPath, layout.outputRoot, previous.raw, entries);
3374
+ return options.health === true ? { report, skipped, written } : { skipped, written };
3375
+ }
3376
+ var MANIFEST_NAME2 = "assets.manifest.json";
3377
+ var DEFAULT_SOURCE2 = "assets";
3378
+ var DEFAULT_OUTPUT2 = "public";
3379
+ var DEFAULT_DEBOUNCE_MS = 100;
3380
+ function messageOf4(error) {
3381
+ return error instanceof Error ? error.message : String(error);
3382
+ }
3383
+ function resolveWatchLayout(cwd, options) {
3384
+ const source = options.source ?? options.config?.source ?? DEFAULT_SOURCE2;
3385
+ const output = options.output ?? options.config?.output ?? DEFAULT_OUTPUT2;
3386
+ const sourceRoot = path.resolve(cwd, source);
3387
+ const outputRoot = path.resolve(cwd, output);
3388
+ return { manifestPath: path.join(outputRoot, MANIFEST_NAME2), outputRoot, sourceRoot };
3389
+ }
3390
+ function readManifest(manifestPath) {
3391
+ const fail = (reason) => new Error(`TN_ASSETS_MANIFEST_INVALID: '${manifestPath}' ${reason}.`);
3392
+ return readFile(manifestPath, "utf8").then(
3393
+ (raw) => {
3394
+ let parsed;
3395
+ try {
3396
+ parsed = JSON.parse(raw);
3397
+ } catch {
3398
+ throw fail("is not valid JSON");
3399
+ }
3400
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || parsed.version !== 1 || typeof parsed.entries !== "object" || parsed.entries === null || Array.isArray(parsed.entries)) {
3401
+ throw fail("must hold version 1 entries");
3402
+ }
3403
+ return { entries: parsed.entries, raw };
3404
+ },
3405
+ () => ({ entries: {}, raw: void 0 })
3406
+ );
3407
+ }
3408
+ async function writeManifestAtomically(manifestPath, entries, raw) {
3409
+ const ordered = {};
3410
+ for (const key of Object.keys(entries).sort()) ordered[key] = entries[key];
3411
+ const serialized = `${JSON.stringify({ version: 1, entries: ordered }, null, 2)}
3412
+ `;
3413
+ if (serialized === raw) return;
3414
+ const temporary = `${manifestPath}.${process.pid}.tmp`;
3415
+ await writeFile(temporary, serialized, "utf8");
3416
+ await rename(temporary, manifestPath);
3417
+ }
3418
+ async function recompileOne(layout, compileOptions, logical) {
3419
+ const scratch = await mkdtemp(path.join(tmpdir(), "threenative-watch-"));
3420
+ try {
3421
+ const stagedInput = path.join(scratch, DEFAULT_SOURCE2, logical);
3422
+ await mkdir(path.dirname(stagedInput), { recursive: true });
3423
+ await writeFile(stagedInput, await readFile(path.join(layout.sourceRoot, logical)));
3424
+ await compileAssets({
3425
+ cwd: scratch,
3426
+ output: "out",
3427
+ source: DEFAULT_SOURCE2,
3428
+ ...compileOptions.config === void 0 ? {} : { config: compileOptions.config },
3429
+ ...compileOptions.passes === void 0 ? {} : { passes: compileOptions.passes },
3430
+ ...compileOptions.transcoder === void 0 ? {} : { transcoder: compileOptions.transcoder }
3431
+ });
3432
+ const scratchManifest = JSON.parse(
3433
+ await readFile(path.join(scratch, "out", MANIFEST_NAME2), "utf8")
3434
+ );
3435
+ const entry = scratchManifest.entries[logical];
3436
+ if (entry === void 0 || entry.output === void 0) {
3437
+ throw new Error("compiled without producing a manifest entry");
3438
+ }
3439
+ const destination = path.join(layout.outputRoot, entry.output);
3440
+ await mkdir(path.dirname(destination), { recursive: true });
3441
+ await writeFile(destination, await readFile(path.join(scratch, "out", entry.output)));
3442
+ return entry;
3443
+ } finally {
3444
+ await rm(scratch, { force: true, recursive: true });
3445
+ }
3446
+ }
3447
+ function watchAssets(options = {}) {
3448
+ const cwd = path.resolve(options.cwd ?? process.cwd());
3449
+ const layout = resolveWatchLayout(cwd, options);
3450
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
3451
+ const transcoder = options.transcoder ?? (() => {
3452
+ try {
3453
+ return resolveBasisTranscoder(cwd);
3454
+ } catch {
3455
+ return void 0;
3456
+ }
3457
+ })();
3458
+ const compileOptions = {
3459
+ config: options.config,
3460
+ cwd,
3461
+ output: options.output,
3462
+ passes: options.passes,
3463
+ source: options.source,
3464
+ ...transcoder === void 0 ? {} : { transcoder }
3465
+ };
3466
+ const logFailure = (message) => {
3467
+ process.stderr.write(`TN_ASSETS_WATCH_FAILED: ${message}
3468
+ `);
3469
+ };
3470
+ let watcher;
3471
+ let timer;
3472
+ let flushing = Promise.resolve();
3473
+ let fullReconcileQueued = false;
3474
+ let closed = false;
3475
+ const queue = /* @__PURE__ */ new Set();
3476
+ const armDebounce = () => {
3477
+ if (timer !== void 0) clearTimeout(timer);
3478
+ timer = setTimeout(() => {
3479
+ timer = void 0;
3480
+ void runFlush();
3481
+ }, debounceMs);
3482
+ };
3483
+ const queueFullReconcile = () => {
3484
+ fullReconcileQueued = true;
3485
+ armDebounce();
3486
+ };
3487
+ const queueEvent = (filename) => {
3488
+ const logical = filename.split(path.sep).join("/").replace(/^\.\//u, "");
3489
+ if (logical.length === 0 || logical.split("/").some((segment) => segment.startsWith("."))) {
3490
+ return;
3491
+ }
3492
+ void stat(path.join(layout.sourceRoot, logical)).then(
3493
+ (info) => {
3494
+ if (!info.isFile()) return;
3495
+ queue.add(logical);
3496
+ armDebounce();
3497
+ },
3498
+ () => {
3499
+ queue.delete(logical);
3500
+ queueFullReconcile();
3501
+ }
3502
+ );
3503
+ };
3504
+ const fullRebuild = async () => {
3505
+ try {
3506
+ await compileAssets(compileOptions);
3507
+ } catch (error) {
3508
+ logFailure(`full reconcile failed: ${messageOf4(error)}`);
3509
+ }
3510
+ };
3511
+ const mergeEntries = async (merged) => {
3512
+ if (merged.size === 0) return;
3513
+ try {
3514
+ const current = await readManifest(layout.manifestPath);
3515
+ for (const [logical, entry] of merged) current.entries[logical] = entry;
3516
+ await writeManifestAtomically(layout.manifestPath, current.entries, current.raw);
3517
+ } catch (error) {
3518
+ logFailure(`manifest update deferred: ${messageOf4(error)}`);
3519
+ }
3520
+ };
3521
+ const reportBurst = (compiled, failed) => {
3522
+ if (options.onChange === void 0 || compiled.length === 0 && failed.length === 0) return;
3523
+ try {
3524
+ options.onChange({ compiled, failed });
3525
+ } catch (error) {
3526
+ logFailure(`onChange listener threw: ${messageOf4(error)}`);
3527
+ }
3528
+ };
3529
+ const flush = async (batch, reconcileAll) => {
3530
+ if (reconcileAll) await fullRebuild();
3531
+ const compiled = [];
3532
+ const failed = [];
3533
+ const merged = /* @__PURE__ */ new Map();
3534
+ for (const logical of batch) {
3535
+ try {
3536
+ merged.set(logical, await recompileOne(layout, compileOptions, logical));
3537
+ compiled.push(logical);
3538
+ } catch (error) {
3539
+ failed.push(logical);
3540
+ logFailure(`could not recompile '${logical}': ${messageOf4(error)}`);
3541
+ }
3542
+ }
3543
+ await mergeEntries(merged);
3544
+ reportBurst(compiled, failed);
3545
+ };
3546
+ const runFlush = () => {
3547
+ const batch = [...queue].sort();
3548
+ queue.clear();
3549
+ const reconcileAll = fullReconcileQueued;
3550
+ fullReconcileQueued = false;
3551
+ flushing = flushing.then(async () => {
3552
+ if (!closed) {
3553
+ try {
3554
+ await flush(batch, reconcileAll);
3555
+ } catch (error) {
3556
+ logFailure(`burst failed: ${messageOf4(error)}`);
3557
+ }
3558
+ }
3559
+ });
3560
+ };
3561
+ const startWatching = () => {
3562
+ try {
3563
+ watcher = watch(layout.sourceRoot, { recursive: true }, (_eventType, filename) => {
3564
+ if (closed) return;
3565
+ if (typeof filename !== "string" || filename.length === 0) {
3566
+ queueFullReconcile();
3567
+ return;
3568
+ }
3569
+ queueEvent(filename);
3570
+ });
3571
+ } catch {
3572
+ return;
3573
+ }
3574
+ watcher.on("error", (error) => {
3575
+ logFailure(messageOf4(error));
3576
+ queueFullReconcile();
3577
+ });
3578
+ };
3579
+ const ready = (async () => {
3580
+ try {
3581
+ await compileAssets(compileOptions);
3582
+ } catch (error) {
3583
+ logFailure(`initial compile failed: ${messageOf4(error)}`);
3584
+ }
3585
+ if (!closed) startWatching();
3586
+ })();
3587
+ return {
3588
+ ready,
3589
+ close: () => {
3590
+ if (closed) return;
3591
+ closed = true;
3592
+ if (timer !== void 0) clearTimeout(timer);
3593
+ timer = void 0;
3594
+ watcher?.close();
3595
+ watcher = void 0;
3596
+ }
3597
+ };
3598
+ }
3599
+
3600
+ export { compileAssets, formatHealthReport, formatModelSizes, formatTextureSizes, lightmapPass, modelPass, parsePng, resolveBasisTranscoder, runHealthReport, texturePass, watchAssets };