@stowkit/three-loader 0.1.2

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.
@@ -0,0 +1,1012 @@
1
+ import * as THREE from 'three';
2
+ import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
3
+ import { StowKitReader } from '@stowkit/reader';
4
+
5
+ class MeshParser {
6
+ /**
7
+ * Parse mesh metadata from binary data
8
+ */
9
+ static parseMeshMetadata(data) {
10
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
11
+ // MeshMetadata structure (140 bytes total when packed)
12
+ const metadata = {
13
+ meshGeometryCount: view.getUint32(0, true),
14
+ materialCount: view.getUint32(4, true),
15
+ nodeCount: view.getUint32(8, true),
16
+ stringId: this.readString(data.slice(12, 140)) // 128 bytes for string
17
+ };
18
+ return metadata;
19
+ }
20
+ /**
21
+ * Parse complete mesh data including geometry, materials, and nodes
22
+ */
23
+ static parseMeshData(metadataBlob, dataBlob) {
24
+ // Ensure we have at least the minimum metadata size
25
+ if (metadataBlob.length < 140) {
26
+ throw new Error(`Metadata blob too small: ${metadataBlob.length} bytes (expected at least 140)`);
27
+ }
28
+ console.log(`Parsing mesh metadata: ${metadataBlob.length} bytes metadata, ${dataBlob.length} bytes data`);
29
+ const metadata = this.parseMeshMetadata(metadataBlob);
30
+ console.log(`Mesh counts: ${metadata.meshGeometryCount} geometries, ${metadata.materialCount} materials, ${metadata.nodeCount} nodes`);
31
+ // Check if the metadata blob contains just the header or all the mesh info
32
+ const expectedMetadataSize = 140 +
33
+ (metadata.meshGeometryCount * 76) +
34
+ (metadata.materialCount * 64) +
35
+ (metadata.nodeCount * 116);
36
+ let hasExtendedMetadata = metadataBlob.length >= expectedMetadataSize;
37
+ console.log(`Expected metadata size: ${expectedMetadataSize}, actual: ${metadataBlob.length}, has extended: ${hasExtendedMetadata}`);
38
+ // If metadata only has the header, the mesh info might be at the start of the data blob
39
+ const sourceBlob = hasExtendedMetadata ? metadataBlob : dataBlob;
40
+ const metaView = new DataView(sourceBlob.buffer, sourceBlob.byteOffset, sourceBlob.byteLength);
41
+ let offset = hasExtendedMetadata ? 140 : 0; // Start after MeshMetadata if in metadata blob, or at beginning of data blob
42
+ // Parse geometry infos
43
+ const geometries = [];
44
+ for (let i = 0; i < metadata.meshGeometryCount; i++) {
45
+ if (offset + 76 > sourceBlob.length) {
46
+ console.warn(`Data truncated at geometry ${i}/${metadata.meshGeometryCount}`);
47
+ break;
48
+ }
49
+ const geo = {
50
+ vertexCount: metaView.getUint32(offset, true),
51
+ indexCount: metaView.getUint32(offset + 4, true),
52
+ vertexBufferOffset: Number(metaView.getBigUint64(offset + 8, true)),
53
+ indexBufferOffset: Number(metaView.getBigUint64(offset + 16, true)),
54
+ normalBufferOffset: Number(metaView.getBigUint64(offset + 24, true)),
55
+ uvBufferOffset: Number(metaView.getBigUint64(offset + 32, true)),
56
+ vertexBufferSize: Number(metaView.getBigUint64(offset + 40, true)),
57
+ indexBufferSize: Number(metaView.getBigUint64(offset + 48, true)),
58
+ normalBufferSize: Number(metaView.getBigUint64(offset + 56, true)),
59
+ uvBufferSize: Number(metaView.getBigUint64(offset + 64, true)),
60
+ materialIndex: metaView.getUint32(offset + 72, true)
61
+ };
62
+ geometries.push(geo);
63
+ offset += 76; // Size of MeshGeometryInfo
64
+ }
65
+ // Parse materials - MaterialData structure: name[64] + schema_id[128] + property_count(4) = 196 bytes
66
+ const materialData = [];
67
+ const materials = [];
68
+ for (let i = 0; i < metadata.materialCount; i++) {
69
+ if (offset + 196 > sourceBlob.length) {
70
+ console.warn(`Data truncated at material ${i}/${metadata.materialCount}`);
71
+ break;
72
+ }
73
+ const name = this.readString(sourceBlob.slice(offset, offset + 64));
74
+ const schemaId = this.readString(sourceBlob.slice(offset + 64, offset + 192));
75
+ const propertyCount = metaView.getUint32(offset + 192, true);
76
+ materialData.push({
77
+ name,
78
+ schemaId,
79
+ propertyCount,
80
+ properties: []
81
+ });
82
+ offset += 196; // Size of MaterialData header
83
+ }
84
+ // Parse nodes first (they come before material properties in the blob)
85
+ const nodes = [];
86
+ console.log(`\n=== Nodes from StowKit file (${metadata.nodeCount} total) ===`);
87
+ for (let i = 0; i < metadata.nodeCount; i++) {
88
+ if (offset + 116 > sourceBlob.length) {
89
+ console.warn(`Data truncated at node ${i}/${metadata.nodeCount}`);
90
+ break;
91
+ }
92
+ const node = {
93
+ name: this.readString(sourceBlob.slice(offset, offset + 64)),
94
+ parentIndex: metaView.getInt32(offset + 64, true),
95
+ position: [
96
+ metaView.getFloat32(offset + 68, true),
97
+ metaView.getFloat32(offset + 72, true),
98
+ metaView.getFloat32(offset + 76, true)
99
+ ],
100
+ rotation: [
101
+ metaView.getFloat32(offset + 80, true),
102
+ metaView.getFloat32(offset + 84, true),
103
+ metaView.getFloat32(offset + 88, true),
104
+ metaView.getFloat32(offset + 92, true)
105
+ ],
106
+ scale: [
107
+ metaView.getFloat32(offset + 96, true),
108
+ metaView.getFloat32(offset + 100, true),
109
+ metaView.getFloat32(offset + 104, true)
110
+ ],
111
+ meshCount: metaView.getUint32(offset + 108, true),
112
+ firstMeshIndex: metaView.getUint32(offset + 112, true)
113
+ };
114
+ console.log(`Node ${i}: "${node.name}" (parent=${node.parentIndex}, meshCount=${node.meshCount}, firstMeshIndex=${node.firstMeshIndex})`);
115
+ nodes.push(node);
116
+ offset += 116; // Size of Node struct
117
+ }
118
+ console.log(`=== End of nodes from file ===\n`);
119
+ // Parse mesh indices
120
+ const totalMeshRefs = nodes.reduce((sum, node) => sum + node.meshCount, 0);
121
+ console.log(`Parsing ${totalMeshRefs} mesh indices at offset ${offset}`);
122
+ const meshIndices = new Uint32Array(totalMeshRefs);
123
+ for (let i = 0; i < totalMeshRefs; i++) {
124
+ if (offset + (i + 1) * 4 > sourceBlob.length) {
125
+ console.warn(`Data truncated at mesh index ${i}/${totalMeshRefs}`);
126
+ break;
127
+ }
128
+ meshIndices[i] = metaView.getUint32(offset + i * 4, true);
129
+ console.log(` meshIndices[${i}] = ${meshIndices[i]}`);
130
+ }
131
+ offset += totalMeshRefs * 4;
132
+ console.log(`After mesh indices, offset = ${offset}`);
133
+ // Second pass: read material properties
134
+ // MaterialPropertyValue: field_name[64] + value[4*4=16] + texture_id[64] = 144 bytes
135
+ for (let i = 0; i < materialData.length; i++) {
136
+ const matData = materialData[i];
137
+ console.log(`Material "${matData.name}": schema="${matData.schemaId}", ${matData.propertyCount} properties`);
138
+ for (let j = 0; j < matData.propertyCount; j++) {
139
+ if (offset + 144 > sourceBlob.length) {
140
+ console.warn(`Data truncated at property ${j}/${matData.propertyCount} of material ${i}`);
141
+ break;
142
+ }
143
+ const fieldName = this.readString(sourceBlob.slice(offset, offset + 64));
144
+ const value = [
145
+ metaView.getFloat32(offset + 64, true),
146
+ metaView.getFloat32(offset + 68, true),
147
+ metaView.getFloat32(offset + 72, true),
148
+ metaView.getFloat32(offset + 76, true)
149
+ ];
150
+ const textureId = this.readString(sourceBlob.slice(offset + 80, offset + 144));
151
+ matData.properties.push({
152
+ fieldName,
153
+ value,
154
+ textureId
155
+ });
156
+ console.log(` Property "${fieldName}": value=[${value.join(', ')}], textureId="${textureId}"`);
157
+ offset += 144; // Size of MaterialPropertyValue
158
+ }
159
+ // Create Three.js material based on properties
160
+ const mat = new THREE.MeshStandardMaterial({
161
+ name: matData.name,
162
+ side: THREE.DoubleSide,
163
+ // Start with default gray color
164
+ color: new THREE.Color(0.8, 0.8, 0.8)
165
+ });
166
+ console.log(`Creating material ${i}: "${matData.name}", ${matData.propertyCount} properties`);
167
+ // Apply properties
168
+ for (const prop of matData.properties) {
169
+ if (prop.fieldName.toLowerCase() === 'tint' || prop.fieldName.toLowerCase().includes('color')) {
170
+ // Apply color/tint
171
+ mat.color = new THREE.Color(prop.value[0], prop.value[1], prop.value[2]);
172
+ console.log(` Applied tint color: rgb(${prop.value[0]}, ${prop.value[1]}, ${prop.value[2]})`);
173
+ }
174
+ }
175
+ console.log(` Material ${i} final color: rgb(${mat.color.r}, ${mat.color.g}, ${mat.color.b})`);
176
+ materials.push(mat);
177
+ }
178
+ // Default material if none exist
179
+ if (materials.length === 0) {
180
+ materials.push(new THREE.MeshStandardMaterial({
181
+ color: new THREE.Color(0.8, 0.8, 0.8), // Light gray
182
+ roughness: 0.5,
183
+ metalness: 0.0,
184
+ side: THREE.DoubleSide
185
+ }));
186
+ }
187
+ return { metadata, geometries, materials, materialData, nodes, meshIndices };
188
+ }
189
+ /**
190
+ * Create Three.js BufferGeometry from mesh data
191
+ */
192
+ static createGeometry(geoInfo, dataBlob) {
193
+ console.log('Creating geometry:', {
194
+ vertexCount: geoInfo.vertexCount,
195
+ indexCount: geoInfo.indexCount,
196
+ vertexOffset: geoInfo.vertexBufferOffset,
197
+ vertexSize: geoInfo.vertexBufferSize,
198
+ dataBlobSize: dataBlob.length
199
+ });
200
+ const geometry = new THREE.BufferGeometry();
201
+ const dataView = new DataView(dataBlob.buffer, dataBlob.byteOffset, dataBlob.byteLength);
202
+ // Extract vertex positions (3 floats per vertex)
203
+ if (geoInfo.vertexBufferSize > 0 && geoInfo.vertexBufferOffset + geoInfo.vertexBufferSize <= dataBlob.length) {
204
+ const positions = new Float32Array(geoInfo.vertexCount * 3);
205
+ for (let i = 0; i < geoInfo.vertexCount * 3; i++) {
206
+ positions[i] = dataView.getFloat32(geoInfo.vertexBufferOffset + i * 4, true);
207
+ }
208
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
209
+ console.log('Added positions:', positions.slice(0, 9)); // First 3 vertices
210
+ }
211
+ else {
212
+ console.warn('No vertex data or out of bounds');
213
+ }
214
+ // Extract normals
215
+ if (geoInfo.normalBufferSize > 0 && geoInfo.normalBufferOffset + geoInfo.normalBufferSize <= dataBlob.length) {
216
+ const normals = new Float32Array(geoInfo.vertexCount * 3);
217
+ for (let i = 0; i < geoInfo.vertexCount * 3; i++) {
218
+ normals[i] = dataView.getFloat32(geoInfo.normalBufferOffset + i * 4, true);
219
+ }
220
+ geometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
221
+ }
222
+ else {
223
+ // Compute normals if not provided
224
+ geometry.computeVertexNormals();
225
+ }
226
+ // Extract UVs
227
+ if (geoInfo.uvBufferSize > 0 && geoInfo.uvBufferOffset + geoInfo.uvBufferSize <= dataBlob.length) {
228
+ const uvs = new Float32Array(geoInfo.vertexCount * 2);
229
+ for (let i = 0; i < geoInfo.vertexCount * 2; i += 2) {
230
+ uvs[i] = dataView.getFloat32(geoInfo.uvBufferOffset + i * 4, true); // U
231
+ uvs[i + 1] = 1.0 - dataView.getFloat32(geoInfo.uvBufferOffset + (i + 1) * 4, true); // V (flipped)
232
+ }
233
+ geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
234
+ }
235
+ // Extract indices
236
+ if (geoInfo.indexCount > 0 && geoInfo.indexBufferOffset + geoInfo.indexBufferSize <= dataBlob.length) {
237
+ const indices = new Uint32Array(geoInfo.indexCount);
238
+ // Read the first index to determine the base offset
239
+ let minIndex = Number.MAX_SAFE_INTEGER;
240
+ for (let i = 0; i < geoInfo.indexCount; i++) {
241
+ const index = dataView.getUint32(geoInfo.indexBufferOffset + i * 4, true);
242
+ if (index < minIndex)
243
+ minIndex = index;
244
+ }
245
+ // Read indices again and offset them to start from 0
246
+ for (let i = 0; i < geoInfo.indexCount; i++) {
247
+ const absoluteIndex = dataView.getUint32(geoInfo.indexBufferOffset + i * 4, true);
248
+ indices[i] = absoluteIndex - minIndex;
249
+ }
250
+ geometry.setIndex(new THREE.BufferAttribute(indices, 1));
251
+ console.log(`Added indices (offset by ${minIndex}):`, indices.slice(0, 12)); // First 4 triangles
252
+ }
253
+ else if (geoInfo.indexCount > 0) {
254
+ console.warn('Index data out of bounds');
255
+ }
256
+ // Ensure we have normals (compute if missing)
257
+ if (!geometry.attributes.normal) {
258
+ geometry.computeVertexNormals();
259
+ }
260
+ geometry.computeBoundingSphere();
261
+ geometry.computeBoundingBox();
262
+ console.log('Final geometry:', {
263
+ hasPositions: !!geometry.attributes.position,
264
+ hasNormals: !!geometry.attributes.normal,
265
+ hasUVs: !!geometry.attributes.uv,
266
+ hasIndices: !!geometry.index,
267
+ vertexCount: geometry.attributes.position?.count || 0
268
+ });
269
+ return geometry;
270
+ }
271
+ /**
272
+ * Build Three.js scene from parsed mesh data
273
+ */
274
+ static buildScene(parsedData, dataBlob) {
275
+ const root = new THREE.Group();
276
+ root.name = 'StowKitMesh';
277
+ const { geometries, materials, nodes, meshIndices } = parsedData;
278
+ console.log('Building scene with:', {
279
+ geometries: geometries.length,
280
+ materials: materials.length,
281
+ nodes: nodes.length,
282
+ meshIndices: meshIndices.length
283
+ });
284
+ // Create all Three.js objects for nodes
285
+ const nodeObjects = [];
286
+ for (const node of nodes) {
287
+ const obj = new THREE.Group();
288
+ obj.name = node.name;
289
+ console.log(`Node ${node.name}: position=${node.position}, rotation=${node.rotation}, scale=${node.scale}, meshCount=${node.meshCount}, firstMeshIndex=${node.firstMeshIndex}`);
290
+ // Apply transform
291
+ obj.position.set(...node.position);
292
+ obj.quaternion.set(...node.rotation);
293
+ obj.scale.set(...node.scale);
294
+ // Add meshes to this node
295
+ for (let i = 0; i < node.meshCount; i++) {
296
+ const meshIndexArrayPos = node.firstMeshIndex + i;
297
+ const meshIndex = meshIndices[meshIndexArrayPos];
298
+ console.log(` meshIndices[${meshIndexArrayPos}] = ${meshIndex} (adding to node ${node.name})`);
299
+ if (meshIndex < geometries.length) {
300
+ const geoInfo = geometries[meshIndex];
301
+ console.log(` Creating geometry from geoInfo: vertices=${geoInfo.vertexCount}, indices=${geoInfo.indexCount}, material=${geoInfo.materialIndex}`);
302
+ const geometry = this.createGeometry(geoInfo, dataBlob);
303
+ // Use assigned material if valid, otherwise create default
304
+ let material;
305
+ if (geoInfo.materialIndex < materials.length) {
306
+ material = materials[geoInfo.materialIndex];
307
+ }
308
+ else {
309
+ console.warn(` Material index ${geoInfo.materialIndex} out of bounds, using default`);
310
+ material = new THREE.MeshStandardMaterial({
311
+ color: new THREE.Color(0.8, 0.8, 0.8),
312
+ roughness: 0.5,
313
+ metalness: 0.0,
314
+ side: THREE.DoubleSide
315
+ });
316
+ }
317
+ const mesh = new THREE.Mesh(geometry, material);
318
+ obj.add(mesh);
319
+ console.log(` ✓ Added mesh to node ${node.name} (${geometry.attributes.position?.count || 0} vertices, material: ${material.name || 'default'})`);
320
+ }
321
+ else {
322
+ console.error(` ✗ Mesh index ${meshIndex} out of bounds (have ${geometries.length} geometries)`);
323
+ }
324
+ }
325
+ console.log(` Node ${node.name} now has ${obj.children.length} children`);
326
+ nodeObjects.push(obj);
327
+ }
328
+ // Build hierarchy
329
+ console.log('Building node hierarchy...');
330
+ for (let i = 0; i < nodes.length; i++) {
331
+ const node = nodes[i];
332
+ const obj = nodeObjects[i];
333
+ if (node.parentIndex >= 0 && node.parentIndex < nodeObjects.length) {
334
+ console.log(` Adding node "${node.name}" (index ${i}) as child of node "${nodes[node.parentIndex].name}" (index ${node.parentIndex})`);
335
+ nodeObjects[node.parentIndex].add(obj);
336
+ }
337
+ else {
338
+ console.log(` Adding node "${node.name}" (index ${i}) as direct child of root (parentIndex=${node.parentIndex})`);
339
+ root.add(obj);
340
+ }
341
+ }
342
+ // If no nodes, create a single mesh from all geometries
343
+ if (nodes.length === 0 && geometries.length > 0) {
344
+ console.log('No nodes found, creating meshes directly from geometries');
345
+ geometries.forEach((geoInfo, index) => {
346
+ console.log(`Creating direct mesh ${index}: materialIndex=${geoInfo.materialIndex}, vertices=${geoInfo.vertexCount}`);
347
+ const geometry = this.createGeometry(geoInfo, dataBlob);
348
+ // Use assigned material if valid, otherwise create default
349
+ let material;
350
+ if (geoInfo.materialIndex < materials.length) {
351
+ material = materials[geoInfo.materialIndex];
352
+ }
353
+ else {
354
+ console.warn(` Material index ${geoInfo.materialIndex} out of bounds, using default`);
355
+ material = new THREE.MeshStandardMaterial({
356
+ color: new THREE.Color(0.8, 0.8, 0.8),
357
+ roughness: 0.5,
358
+ metalness: 0.0,
359
+ side: THREE.DoubleSide
360
+ });
361
+ }
362
+ const mesh = new THREE.Mesh(geometry, material);
363
+ mesh.name = `Mesh_${index}`;
364
+ root.add(mesh);
365
+ console.log(`Added mesh ${index} to root`);
366
+ });
367
+ }
368
+ console.log(`Final scene has ${root.children.length} direct children`);
369
+ // Debug: traverse and count all meshes, showing full hierarchy
370
+ let totalMeshCount = 0;
371
+ let hierarchyLog = 'Scene hierarchy:\n';
372
+ root.traverse((obj) => {
373
+ // Calculate depth by counting parents
374
+ let depth = 0;
375
+ let parent = obj.parent;
376
+ while (parent && parent !== root) {
377
+ depth++;
378
+ parent = parent.parent;
379
+ }
380
+ const indent = ' '.repeat(depth);
381
+ if (obj instanceof THREE.Mesh) {
382
+ totalMeshCount++;
383
+ const mat = obj.material;
384
+ hierarchyLog += `${indent}[Mesh] vertices=${obj.geometry.attributes.position?.count || 0}, material=${mat.name}, hasTexture=${!!mat.map}\n`;
385
+ }
386
+ else if (obj instanceof THREE.Group && obj !== root) {
387
+ hierarchyLog += `${indent}[Group] ${obj.name} (${obj.children.length} children)\n`;
388
+ }
389
+ });
390
+ console.log(hierarchyLog);
391
+ console.log(`Total meshes in scene: ${totalMeshCount}`);
392
+ return root;
393
+ }
394
+ /**
395
+ * Read null-terminated string from buffer
396
+ */
397
+ static readString(buffer) {
398
+ let end = buffer.indexOf(0);
399
+ if (end === -1)
400
+ end = buffer.length;
401
+ return new TextDecoder().decode(buffer.slice(0, end));
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Three.js loader for StowKit asset packs (.stow files)
407
+ *
408
+ * Usage:
409
+ * ```typescript
410
+ * const loader = new StowKitLoader();
411
+ * loader.setTranscoderPath('/basis/');
412
+ *
413
+ * // Load a mesh asset
414
+ * loader.loadMesh('assets.stow', 'path/to/mesh', (scene) => {
415
+ * threeScene.add(scene);
416
+ * });
417
+ * ```
418
+ */
419
+ class StowKitLoader extends THREE.Loader {
420
+ constructor(manager, parameters) {
421
+ super(manager);
422
+ this.ownedReader = false;
423
+ // Use provided reader or create new one
424
+ if (parameters?.reader) {
425
+ this.reader = parameters.reader;
426
+ this.ownedReader = false;
427
+ }
428
+ else {
429
+ const wasmPath = parameters?.wasmPath || '/stowkit_reader.wasm';
430
+ this.reader = new StowKitReader(wasmPath);
431
+ this.ownedReader = true;
432
+ }
433
+ // Setup KTX2 loader for textures with default path
434
+ const transcoderPath = parameters?.transcoderPath || '/basis/';
435
+ this.ktx2Loader = new KTX2Loader(manager);
436
+ this.ktx2Loader.setTranscoderPath(transcoderPath);
437
+ // Detect support with a temporary renderer
438
+ // This is required for KTX2Loader to work
439
+ const tempRenderer = new THREE.WebGLRenderer();
440
+ this.ktx2Loader.detectSupport(tempRenderer);
441
+ tempRenderer.dispose();
442
+ }
443
+ /**
444
+ * Set the path to the Basis Universal transcoder
445
+ */
446
+ setTranscoderPath(path) {
447
+ this.ktx2Loader.setTranscoderPath(path);
448
+ return this;
449
+ }
450
+ /**
451
+ * Detect WebGL support for compressed textures
452
+ */
453
+ detectSupport(renderer) {
454
+ this.ktx2Loader.detectSupport(renderer);
455
+ return this;
456
+ }
457
+ /**
458
+ * Open a .stow pack file (call this first if loading multiple assets)
459
+ * @param url - URL to the .stow file
460
+ */
461
+ async openPack(url) {
462
+ if (this.ownedReader) {
463
+ await this.reader.init();
464
+ }
465
+ const response = await fetch(url);
466
+ if (!response.ok) {
467
+ throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
468
+ }
469
+ const arrayBuffer = await response.arrayBuffer();
470
+ await this.reader.open(arrayBuffer);
471
+ }
472
+ /**
473
+ * Load and parse a mesh asset from a StowKit pack
474
+ * @param url - URL to the .stow file (or omit if already opened with openPack)
475
+ * @param assetPath - Path to the mesh asset within the pack
476
+ * @param onLoad - Callback when loading completes
477
+ * @param onProgress - Progress callback
478
+ * @param onError - Error callback
479
+ */
480
+ async loadMesh(url, assetPath, onLoad, onProgress, onError) {
481
+ try {
482
+ // Open pack if URL provided
483
+ if (url) {
484
+ await this.openPack(url);
485
+ }
486
+ // Find the mesh asset
487
+ const assetIndex = this.reader.findAssetByPath(assetPath);
488
+ if (assetIndex < 0) {
489
+ throw new Error(`Asset not found: ${assetPath}`);
490
+ }
491
+ // Read mesh data and metadata
492
+ const data = this.reader.readAssetData(assetIndex);
493
+ const metadata = this.reader.readAssetMetadata(assetIndex);
494
+ if (!data) {
495
+ throw new Error(`Failed to read mesh data for ${assetPath}`);
496
+ }
497
+ if (!metadata) {
498
+ throw new Error(`No metadata available for ${assetPath}`);
499
+ }
500
+ // Parse mesh data
501
+ const parsedData = MeshParser.parseMeshData(metadata, data);
502
+ // Load textures for materials
503
+ await this.loadMaterialTextures(parsedData.materialData, parsedData.materials);
504
+ // Build Three.js scene
505
+ const scene = MeshParser.buildScene(parsedData, data);
506
+ if (onLoad) {
507
+ onLoad(scene);
508
+ }
509
+ return scene;
510
+ }
511
+ catch (error) {
512
+ if (onError) {
513
+ onError(error);
514
+ }
515
+ throw error;
516
+ }
517
+ }
518
+ /**
519
+ * Load textures for materials
520
+ */
521
+ async loadMaterialTextures(materialData, materials) {
522
+ for (let i = 0; i < materialData.length; i++) {
523
+ const matData = materialData[i];
524
+ const material = materials[i];
525
+ for (const prop of matData.properties) {
526
+ if (prop.textureId && prop.textureId.length > 0) {
527
+ try {
528
+ // Find texture asset
529
+ const textureIndex = this.reader.findAssetByPath(prop.textureId);
530
+ if (textureIndex < 0) {
531
+ console.warn(`Texture not found: ${prop.textureId}`);
532
+ continue;
533
+ }
534
+ // Read texture data
535
+ const textureData = this.reader.readAssetData(textureIndex);
536
+ if (!textureData) {
537
+ console.warn(`Failed to read texture: ${prop.textureId}`);
538
+ continue;
539
+ }
540
+ // Load as KTX2
541
+ const texture = await this.loadKTX2Texture(textureData);
542
+ // Apply to material based on property name
543
+ this.applyTextureToMaterial(material, prop.fieldName, texture);
544
+ }
545
+ catch (error) {
546
+ console.error(`Failed to load texture "${prop.textureId}":`, error);
547
+ }
548
+ }
549
+ }
550
+ }
551
+ }
552
+ /**
553
+ * Load a KTX2 texture from binary data
554
+ */
555
+ async loadKTX2Texture(data) {
556
+ const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
557
+ const blob = new Blob([arrayBuffer]);
558
+ const url = URL.createObjectURL(blob);
559
+ try {
560
+ return await new Promise((resolve, reject) => {
561
+ this.ktx2Loader.load(url, (texture) => {
562
+ URL.revokeObjectURL(url);
563
+ // Compressed textures don't support flipY, we flip UVs in MeshParser instead
564
+ texture.needsUpdate = true;
565
+ resolve(texture);
566
+ }, undefined, (error) => {
567
+ URL.revokeObjectURL(url);
568
+ reject(error);
569
+ });
570
+ });
571
+ }
572
+ catch (error) {
573
+ URL.revokeObjectURL(url);
574
+ throw error;
575
+ }
576
+ }
577
+ /**
578
+ * Apply texture to appropriate material property
579
+ */
580
+ applyTextureToMaterial(material, propertyName, texture) {
581
+ const propNameLower = propertyName.toLowerCase();
582
+ if (propNameLower === 'maintex' || propNameLower.includes('diffuse') ||
583
+ propNameLower.includes('albedo') || propNameLower.includes('base')) {
584
+ material.map = texture;
585
+ }
586
+ else if (propNameLower.includes('normal')) {
587
+ material.normalMap = texture;
588
+ }
589
+ else if (propNameLower.includes('metallic')) {
590
+ material.metalnessMap = texture;
591
+ }
592
+ else if (propNameLower.includes('roughness')) {
593
+ material.roughnessMap = texture;
594
+ }
595
+ else {
596
+ // Default to main texture
597
+ material.map = texture;
598
+ }
599
+ material.needsUpdate = true;
600
+ }
601
+ /**
602
+ * Load a texture asset from a StowKit pack
603
+ * @param url - URL to the .stow file (or omit if already opened with openPack)
604
+ * @param assetPath - Path to the texture asset within the pack
605
+ * @param onLoad - Callback when loading completes
606
+ * @param onProgress - Progress callback
607
+ * @param onError - Error callback
608
+ */
609
+ async loadTexture(url, assetPath, onLoad, onProgress, onError) {
610
+ try {
611
+ // Open pack if URL provided
612
+ if (url) {
613
+ await this.openPack(url);
614
+ }
615
+ // Find the texture asset
616
+ const assetIndex = this.reader.findAssetByPath(assetPath);
617
+ if (assetIndex < 0) {
618
+ throw new Error(`Asset not found: ${assetPath}`);
619
+ }
620
+ return await this.loadTextureByIndex(assetIndex, onLoad, onProgress, onError);
621
+ }
622
+ catch (error) {
623
+ if (onError) {
624
+ onError(error);
625
+ }
626
+ throw error;
627
+ }
628
+ }
629
+ /**
630
+ * Load a texture asset by its index in the pack
631
+ * @param index - Asset index
632
+ * @param onLoad - Callback when loading completes
633
+ * @param onProgress - Progress callback
634
+ * @param onError - Error callback
635
+ */
636
+ async loadTextureByIndex(index, onLoad, onProgress, onError) {
637
+ try {
638
+ // Read texture data
639
+ const data = this.reader.readAssetData(index);
640
+ if (!data) {
641
+ throw new Error(`Failed to read texture data for index ${index}`);
642
+ }
643
+ // Load as KTX2
644
+ const texture = await this.loadKTX2Texture(data);
645
+ if (onLoad) {
646
+ onLoad(texture);
647
+ }
648
+ return texture;
649
+ }
650
+ catch (error) {
651
+ if (onError) {
652
+ onError(error);
653
+ }
654
+ throw error;
655
+ }
656
+ }
657
+ /**
658
+ * Load an audio asset from a StowKit pack
659
+ * @param url - URL to the .stow file (or omit if already opened with openPack)
660
+ * @param assetPath - Path to the audio asset within the pack
661
+ * @param listener - THREE.AudioListener to attach to
662
+ * @param onLoad - Callback when loading completes
663
+ * @param onProgress - Progress callback
664
+ * @param onError - Error callback
665
+ */
666
+ async loadAudio(url, assetPath, listener, onLoad, onProgress, onError) {
667
+ try {
668
+ // Open pack if URL provided
669
+ if (url) {
670
+ await this.openPack(url);
671
+ }
672
+ // Find the audio asset
673
+ const assetIndex = this.reader.findAssetByPath(assetPath);
674
+ if (assetIndex < 0) {
675
+ throw new Error(`Asset not found: ${assetPath}`);
676
+ }
677
+ // Read audio data
678
+ const data = this.reader.readAssetData(assetIndex);
679
+ if (!data) {
680
+ throw new Error(`Failed to read audio data for ${assetPath}`);
681
+ }
682
+ // Create audio object
683
+ const audio = new THREE.Audio(listener);
684
+ // Decode audio data
685
+ const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
686
+ const audioContext = listener.context;
687
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
688
+ audio.setBuffer(audioBuffer);
689
+ if (onLoad) {
690
+ onLoad(audio);
691
+ }
692
+ return audio;
693
+ }
694
+ catch (error) {
695
+ if (onError) {
696
+ onError(error);
697
+ }
698
+ throw error;
699
+ }
700
+ }
701
+ /**
702
+ * Get metadata for an audio asset
703
+ * @param assetPath - Path to the audio asset within the pack
704
+ */
705
+ getAudioMetadata(assetPath) {
706
+ try {
707
+ const assetIndex = this.reader.findAssetByPath(assetPath);
708
+ if (assetIndex < 0)
709
+ return null;
710
+ const metadata = this.reader.readAssetMetadata(assetIndex);
711
+ if (!metadata || metadata.length < 140)
712
+ return null;
713
+ const view = new DataView(metadata.buffer, metadata.byteOffset, metadata.byteLength);
714
+ const decoder = new TextDecoder();
715
+ // Parse AudioMetadata structure
716
+ const stringIdBytes = metadata.slice(0, 128);
717
+ const nullIdx = stringIdBytes.indexOf(0);
718
+ const stringId = decoder.decode(stringIdBytes.slice(0, nullIdx >= 0 ? nullIdx : 128));
719
+ return {
720
+ stringId,
721
+ sampleRate: view.getUint32(128, true),
722
+ channels: view.getUint32(132, true),
723
+ durationMs: view.getUint32(136, true)
724
+ };
725
+ }
726
+ catch (error) {
727
+ console.error('Failed to read audio metadata:', error);
728
+ return null;
729
+ }
730
+ }
731
+ /**
732
+ * Get metadata for a texture asset
733
+ * @param assetPath - Path to the texture asset within the pack
734
+ */
735
+ getTextureMetadata(assetPath) {
736
+ try {
737
+ const assetIndex = this.reader.findAssetByPath(assetPath);
738
+ if (assetIndex < 0)
739
+ return null;
740
+ const metadata = this.reader.readAssetMetadata(assetIndex);
741
+ if (!metadata || metadata.length < 148)
742
+ return null;
743
+ const view = new DataView(metadata.buffer, metadata.byteOffset, metadata.byteLength);
744
+ const decoder = new TextDecoder();
745
+ // Parse TextureMetadata structure
746
+ const stringIdBytes = metadata.slice(20, 148);
747
+ const nullIdx = stringIdBytes.indexOf(0);
748
+ const stringId = decoder.decode(stringIdBytes.slice(0, nullIdx >= 0 ? nullIdx : 128));
749
+ return {
750
+ width: view.getUint32(0, true),
751
+ height: view.getUint32(4, true),
752
+ channels: view.getUint32(8, true),
753
+ isKtx2: view.getUint32(12, true) === 1,
754
+ channelFormat: view.getUint32(16, true),
755
+ stringId
756
+ };
757
+ }
758
+ catch (error) {
759
+ console.error('Failed to read texture metadata:', error);
760
+ return null;
761
+ }
762
+ }
763
+ /**
764
+ * Load material schema information by index
765
+ * @param index - Asset index
766
+ */
767
+ loadMaterialSchemaByIndex(index) {
768
+ try {
769
+ const metadata = this.reader.readAssetMetadata(index);
770
+ if (!metadata || metadata.length < 196)
771
+ return null;
772
+ const view = new DataView(metadata.buffer, metadata.byteOffset, metadata.byteLength);
773
+ const decoder = new TextDecoder();
774
+ const stringIdBytes = metadata.slice(0, 128);
775
+ const schemaNameBytes = metadata.slice(128, 192);
776
+ const stringId = decoder.decode(stringIdBytes.slice(0, stringIdBytes.indexOf(0) || 128));
777
+ const schemaName = decoder.decode(schemaNameBytes.slice(0, schemaNameBytes.indexOf(0) || 64));
778
+ const fieldCount = view.getUint32(192, true);
779
+ const fields = [];
780
+ let offset = 196;
781
+ for (let i = 0; i < fieldCount; i++) {
782
+ if (offset + 88 > metadata.length)
783
+ break;
784
+ const fieldNameBytes = metadata.slice(offset, offset + 64);
785
+ const fieldName = decoder.decode(fieldNameBytes.slice(0, fieldNameBytes.indexOf(0) || 64));
786
+ const fieldType = view.getUint32(offset + 64, true);
787
+ const previewFlags = view.getUint32(offset + 68, true);
788
+ const defaultValue = [
789
+ view.getFloat32(offset + 72, true),
790
+ view.getFloat32(offset + 76, true),
791
+ view.getFloat32(offset + 80, true),
792
+ view.getFloat32(offset + 84, true)
793
+ ];
794
+ const typeNames = ['Texture', 'Color', 'Float', 'Vec2', 'Vec3', 'Vec4', 'Int'];
795
+ const previewFlagNames = ['None', 'MainTex', 'Tint'];
796
+ fields.push({
797
+ name: fieldName,
798
+ type: typeNames[fieldType] || 'Float',
799
+ previewFlag: previewFlagNames[previewFlags] || 'None',
800
+ defaultValue
801
+ });
802
+ offset += 88;
803
+ }
804
+ return { stringId, schemaName, fields };
805
+ }
806
+ catch (error) {
807
+ console.error('Failed to read material schema:', error);
808
+ return null;
809
+ }
810
+ }
811
+ /**
812
+ * Load material schema information by path
813
+ * @param assetPath - Path to the material schema asset
814
+ */
815
+ loadMaterialSchema(assetPath) {
816
+ try {
817
+ const assetIndex = this.reader.findAssetByPath(assetPath);
818
+ if (assetIndex < 0)
819
+ return null;
820
+ const metadata = this.reader.readAssetMetadata(assetIndex);
821
+ if (!metadata || metadata.length < 196)
822
+ return null;
823
+ const view = new DataView(metadata.buffer, metadata.byteOffset, metadata.byteLength);
824
+ const decoder = new TextDecoder();
825
+ // Parse MaterialSchemaMetadata: string_id[128] + schema_name[64] + field_count(4) = 196 bytes
826
+ const stringIdBytes = metadata.slice(0, 128);
827
+ const schemaNameBytes = metadata.slice(128, 192);
828
+ const stringId = decoder.decode(stringIdBytes.slice(0, stringIdBytes.indexOf(0) || 128));
829
+ const schemaName = decoder.decode(schemaNameBytes.slice(0, schemaNameBytes.indexOf(0) || 64));
830
+ const fieldCount = view.getUint32(192, true);
831
+ const fields = [];
832
+ let offset = 196;
833
+ // Parse fields: name[64] + field_type(4) + preview_flags(4) + default_value[4*4=16] = 88 bytes
834
+ for (let i = 0; i < fieldCount; i++) {
835
+ if (offset + 88 > metadata.length)
836
+ break;
837
+ const fieldNameBytes = metadata.slice(offset, offset + 64);
838
+ const fieldName = decoder.decode(fieldNameBytes.slice(0, fieldNameBytes.indexOf(0) || 64));
839
+ const fieldType = view.getUint32(offset + 64, true);
840
+ const previewFlags = view.getUint32(offset + 68, true);
841
+ const defaultValue = [
842
+ view.getFloat32(offset + 72, true),
843
+ view.getFloat32(offset + 76, true),
844
+ view.getFloat32(offset + 80, true),
845
+ view.getFloat32(offset + 84, true)
846
+ ];
847
+ const typeNames = ['Texture', 'Color', 'Float', 'Vec2', 'Vec3', 'Vec4', 'Int'];
848
+ const previewFlagNames = ['None', 'MainTex', 'Tint'];
849
+ fields.push({
850
+ name: fieldName,
851
+ type: typeNames[fieldType] || 'Float',
852
+ previewFlag: previewFlagNames[previewFlags] || 'None',
853
+ defaultValue
854
+ });
855
+ offset += 88; // MaterialSchemaField is 88 bytes total
856
+ }
857
+ return { stringId, schemaName, fields };
858
+ }
859
+ catch (error) {
860
+ console.error('Failed to read material schema:', error);
861
+ return null;
862
+ }
863
+ }
864
+ /**
865
+ * Get material information from a loaded mesh
866
+ * Returns material data including properties and texture references
867
+ */
868
+ getMeshMaterials(assetPath) {
869
+ try {
870
+ const assetIndex = this.reader.findAssetByPath(assetPath);
871
+ if (assetIndex < 0)
872
+ return null;
873
+ const metadata = this.reader.readAssetMetadata(assetIndex);
874
+ if (!metadata || metadata.length < 140)
875
+ return null;
876
+ const view = new DataView(metadata.buffer, metadata.byteOffset, metadata.byteLength);
877
+ const decoder = new TextDecoder();
878
+ // Parse MeshMetadata
879
+ const meshGeometryCount = view.getUint32(0, true);
880
+ const materialCount = view.getUint32(4, true);
881
+ const nodeCount = view.getUint32(8, true);
882
+ let offset = 140;
883
+ // Skip geometries (76 bytes each)
884
+ offset += meshGeometryCount * 76;
885
+ // Parse materials
886
+ const materials = [];
887
+ for (let i = 0; i < materialCount; i++) {
888
+ if (offset + 196 > metadata.length)
889
+ break;
890
+ const nameBytes = metadata.slice(offset, offset + 64);
891
+ const schemaIdBytes = metadata.slice(offset + 64, offset + 192);
892
+ const name = decoder.decode(nameBytes.slice(0, nameBytes.indexOf(0) || 64));
893
+ const schemaId = decoder.decode(schemaIdBytes.slice(0, schemaIdBytes.indexOf(0) || 128));
894
+ const propertyCount = view.getUint32(offset + 192, true);
895
+ materials.push({ name, schemaId, propertyCount, properties: [] });
896
+ offset += 196;
897
+ }
898
+ // Skip nodes (116 bytes each)
899
+ offset += nodeCount * 116;
900
+ // Skip mesh indices (4 bytes each)
901
+ const totalMeshRefs = materials.reduce((sum, mat) => {
902
+ // This is a simplification - we'd need to parse nodes to get exact count
903
+ return sum;
904
+ }, 0);
905
+ // For now, skip based on nodeCount assumption
906
+ offset += nodeCount * 4;
907
+ // Parse material properties
908
+ for (const mat of materials) {
909
+ for (let j = 0; j < mat.propertyCount; j++) {
910
+ if (offset + 144 > metadata.length)
911
+ break;
912
+ const fieldNameBytes = metadata.slice(offset, offset + 64);
913
+ const fieldName = decoder.decode(fieldNameBytes.slice(0, fieldNameBytes.indexOf(0) || 64));
914
+ const value = [
915
+ view.getFloat32(offset + 64, true),
916
+ view.getFloat32(offset + 68, true),
917
+ view.getFloat32(offset + 72, true),
918
+ view.getFloat32(offset + 76, true)
919
+ ];
920
+ const textureIdBytes = metadata.slice(offset + 80, offset + 144);
921
+ const textureId = decoder.decode(textureIdBytes.slice(0, textureIdBytes.indexOf(0) || 64));
922
+ mat.properties.push({ fieldName, value, textureId });
923
+ offset += 144;
924
+ }
925
+ }
926
+ return materials;
927
+ }
928
+ catch (error) {
929
+ console.error('Failed to read mesh materials:', error);
930
+ return null;
931
+ }
932
+ }
933
+ /**
934
+ * Open a .stow file from a File or ArrayBuffer
935
+ */
936
+ async open(fileOrBuffer) {
937
+ // Initialize reader if needed
938
+ if (this.ownedReader) {
939
+ await this.reader.init();
940
+ }
941
+ return await this.reader.open(fileOrBuffer);
942
+ }
943
+ /**
944
+ * Get the total number of assets
945
+ */
946
+ getAssetCount() {
947
+ return this.reader.getAssetCount();
948
+ }
949
+ /**
950
+ * Get info about a specific asset
951
+ */
952
+ getAssetInfo(index) {
953
+ return this.reader.getAssetInfo(index);
954
+ }
955
+ /**
956
+ * Read asset data by index
957
+ */
958
+ readAssetData(index) {
959
+ return this.reader.readAssetData(index);
960
+ }
961
+ /**
962
+ * Read asset metadata by index
963
+ */
964
+ readAssetMetadata(index) {
965
+ return this.reader.readAssetMetadata(index);
966
+ }
967
+ /**
968
+ * Parse texture metadata
969
+ */
970
+ parseTextureMetadata(metadataBytes) {
971
+ return this.reader.parseTextureMetadata(metadataBytes);
972
+ }
973
+ /**
974
+ * Create an HTML audio element for preview
975
+ * @param index - Asset index
976
+ */
977
+ async createAudioPreview(index) {
978
+ const data = this.reader.readAssetData(index);
979
+ if (!data) {
980
+ throw new Error(`Failed to read audio data for index ${index}`);
981
+ }
982
+ // Create blob URL for HTML5 audio
983
+ const blob = new Blob([data], { type: 'audio/ogg' });
984
+ const url = URL.createObjectURL(blob);
985
+ // Create and configure audio element
986
+ const audio = document.createElement('audio');
987
+ audio.controls = true;
988
+ audio.src = url;
989
+ // Clean up URL when audio ends or on error
990
+ audio.addEventListener('ended', () => URL.revokeObjectURL(url));
991
+ audio.addEventListener('error', () => URL.revokeObjectURL(url));
992
+ return audio;
993
+ }
994
+ /**
995
+ * List all assets in the opened pack
996
+ */
997
+ listAssets() {
998
+ return this.reader.listAssets();
999
+ }
1000
+ /**
1001
+ * Dispose of resources
1002
+ */
1003
+ dispose() {
1004
+ this.ktx2Loader.dispose();
1005
+ if (this.ownedReader) {
1006
+ this.reader.close();
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ export { MeshParser, StowKitLoader };
1012
+ //# sourceMappingURL=stowkit-three-loader.esm.js.map