@stowkit/three-loader 0.1.15 → 0.1.16

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.
@@ -2,6 +2,7 @@ import * as THREE from 'three';
2
2
  import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
3
3
  import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
4
4
  import { StowKitReader } from '@stowkit/reader';
5
+ export { AssetType } from '@stowkit/reader';
5
6
 
6
7
  class MeshParser {
7
8
  /**
@@ -306,6 +307,317 @@ class StowKitPack {
306
307
  this.ktx2Loader = ktx2Loader;
307
308
  this.dracoLoader = dracoLoader;
308
309
  }
310
+ /**
311
+ * Load a skinned mesh by its string ID
312
+ */
313
+ async loadSkinnedMesh(stringId) {
314
+ const assetIndex = this.reader.findAssetByPath(stringId);
315
+ if (assetIndex < 0) {
316
+ throw new Error(`Skinned mesh not found: ${stringId}`);
317
+ }
318
+ const data = this.reader.readAssetData(assetIndex);
319
+ const metadata = this.reader.readAssetMetadata(assetIndex);
320
+ if (!data)
321
+ throw new Error(`Failed to read skinned mesh data: ${stringId}`);
322
+ if (!metadata)
323
+ throw new Error(`No metadata for skinned mesh: ${stringId}`);
324
+ return await this.parseSkinnedMesh(metadata, data);
325
+ }
326
+ /**
327
+ * Parse skinned mesh from binary data
328
+ */
329
+ async parseSkinnedMesh(metadata, data) {
330
+ // Parse skinned mesh metadata
331
+ const view = new DataView(metadata.buffer, metadata.byteOffset, metadata.byteLength);
332
+ const decoder = new TextDecoder();
333
+ // SkinnedMeshMetadata: 4 uint32s (16 bytes) THEN string_id[128] = 144 bytes total
334
+ const meshGeometryCount = view.getUint32(0, true);
335
+ const materialCount = view.getUint32(4, true);
336
+ const nodeCount = view.getUint32(8, true);
337
+ const boneCount = view.getUint32(12, true);
338
+ const stringIdBytes = metadata.slice(16, 144);
339
+ const stringIdNullIndex = stringIdBytes.indexOf(0);
340
+ const stringId = decoder
341
+ .decode(stringIdBytes.slice(0, stringIdNullIndex >= 0 ? stringIdNullIndex : 128))
342
+ .trim();
343
+ let offset = 144;
344
+ // Parse all geometry infos
345
+ const geometryInfos = [];
346
+ // SkinnedMeshGeometryInfo: 4 uint32s + 6 uint64s + 2 uint32s = 16 + 48 + 8 = 72 bytes
347
+ // Layout: vertex_count, index_count, has_normals, has_uvs (16 bytes)
348
+ // vertex_buffer_offset, vertex_buffer_size, index_buffer_offset, index_buffer_size, weights_offset, weights_size (48 bytes)
349
+ // material_index, _padding (8 bytes)
350
+ for (let g = 0; g < meshGeometryCount; g++) {
351
+ if (offset + 72 > metadata.length) {
352
+ throw new Error(`Metadata too small for geometry ${g}`);
353
+ }
354
+ geometryInfos.push({
355
+ vertexCount: view.getUint32(offset, true),
356
+ indexCount: view.getUint32(offset + 4, true),
357
+ hasNormals: view.getUint32(offset + 8, true),
358
+ hasUVs: view.getUint32(offset + 12, true),
359
+ vertexBufferOffset: Number(view.getBigUint64(offset + 16, true)),
360
+ vertexBufferSize: Number(view.getBigUint64(offset + 24, true)),
361
+ indexBufferOffset: Number(view.getBigUint64(offset + 32, true)),
362
+ indexBufferSize: Number(view.getBigUint64(offset + 40, true)),
363
+ weightsOffset: Number(view.getBigUint64(offset + 48, true)),
364
+ weightsSize: Number(view.getBigUint64(offset + 56, true)),
365
+ materialIndex: view.getUint32(offset + 64, true)
366
+ // _padding at offset + 68 (ignored)
367
+ });
368
+ offset += 72;
369
+ }
370
+ const dataView = new DataView(data.buffer, data.byteOffset, data.byteLength);
371
+ // Sort geometry infos by vertex buffer offset to ensure sequential parsing
372
+ const sortedGeometryInfos = geometryInfos.slice().sort((a, b) => a.vertexBufferOffset - b.vertexBufferOffset);
373
+ // Parse materials from metadata (immediately after geometries)
374
+ let metadataOffset = 144 + meshGeometryCount * 72;
375
+ const materialData = [];
376
+ // Parse MaterialData headers
377
+ for (let i = 0; i < materialCount; i++) {
378
+ if (metadataOffset + 196 > metadata.length) {
379
+ console.warn('Skinned mesh metadata truncated while reading material headers');
380
+ break;
381
+ }
382
+ const nameBytes = metadata.slice(metadataOffset, metadataOffset + 64);
383
+ const schemaIdBytes = metadata.slice(metadataOffset + 64, metadataOffset + 192);
384
+ const name = decoder.decode(nameBytes.slice(0, nameBytes.indexOf(0) || 64));
385
+ const schemaId = decoder.decode(schemaIdBytes.slice(0, schemaIdBytes.indexOf(0) || 128));
386
+ const propertyCount = view.getUint32(metadataOffset + 192, true);
387
+ materialData.push({ name, schemaId, propertyCount, properties: [] });
388
+ metadataOffset += 196;
389
+ }
390
+ // Skip Node array and node_mesh_indices (not needed for rendering)
391
+ // Node struct: transform[16 floats] + name[64 bytes] + mesh_index[4 bytes] + parent_index[4 bytes] = 116 bytes
392
+ const NODE_STRIDE = 116;
393
+ metadataOffset += nodeCount * NODE_STRIDE;
394
+ metadataOffset += nodeCount * 4;
395
+ // Parse material properties
396
+ const PROPERTY_STRIDE = 144;
397
+ for (let i = 0; i < materialCount; i++) {
398
+ const mat = materialData[i];
399
+ for (let p = 0; p < mat.propertyCount; p++) {
400
+ try {
401
+ if (metadataOffset + PROPERTY_STRIDE > metadata.length) {
402
+ break;
403
+ }
404
+ const fieldNameBytes = metadata.slice(metadataOffset, metadataOffset + 64);
405
+ const fieldName = decoder
406
+ .decode(fieldNameBytes.slice(0, fieldNameBytes.indexOf(0) || 64))
407
+ .trim();
408
+ const value = [
409
+ view.getFloat32(metadataOffset + 64, true),
410
+ view.getFloat32(metadataOffset + 68, true),
411
+ view.getFloat32(metadataOffset + 72, true),
412
+ view.getFloat32(metadataOffset + 76, true)
413
+ ];
414
+ const textureIdBytes = metadata.slice(metadataOffset + 80, metadataOffset + 144);
415
+ const textureIdNullIndex = textureIdBytes.indexOf(0);
416
+ const textureId = (textureIdNullIndex >= 0
417
+ ? decoder.decode(textureIdBytes.slice(0, textureIdNullIndex))
418
+ : decoder.decode(textureIdBytes))
419
+ .trim();
420
+ mat.properties.push({ fieldName, value, textureId });
421
+ metadataOffset += PROPERTY_STRIDE;
422
+ }
423
+ catch (err) {
424
+ throw err;
425
+ }
426
+ }
427
+ }
428
+ // Parse bones
429
+ const BONE_STRIDE = 64 + 4 + 16 * 4;
430
+ const bones = [];
431
+ const parentIndices = [];
432
+ const boneMatrices = [];
433
+ const boneOriginalNames = [];
434
+ const bindPoses = [];
435
+ for (let i = 0; i < boneCount; i++) {
436
+ if (metadataOffset + BONE_STRIDE > metadata.length) {
437
+ throw new Error(`Skinned mesh metadata truncated while reading bone ${i}`);
438
+ }
439
+ const nameBytes = new Uint8Array(metadata.buffer, metadata.byteOffset + metadataOffset, 64);
440
+ const nullIndex = nameBytes.indexOf(0);
441
+ const rawName = decoder.decode(nameBytes.subarray(0, nullIndex >= 0 ? nullIndex : 64)).trim();
442
+ const parentIndex = view.getInt32(metadataOffset + 64, true);
443
+ const offsetMatrix = new THREE.Matrix4();
444
+ const matrixElements = new Float32Array(16);
445
+ for (let j = 0; j < 16; j++) {
446
+ matrixElements[j] = view.getFloat32(metadataOffset + 68 + j * 4, true);
447
+ }
448
+ offsetMatrix.fromArray(matrixElements);
449
+ const sanitizedName = this.sanitizeTrackName(rawName);
450
+ const bone = new THREE.Bone();
451
+ bone.name = sanitizedName;
452
+ bone.userData.originalName = rawName;
453
+ bones.push(bone);
454
+ parentIndices.push(parentIndex);
455
+ boneMatrices.push(offsetMatrix);
456
+ boneOriginalNames.push(rawName);
457
+ bindPoses.push(offsetMatrix.clone().invert());
458
+ metadataOffset += BONE_STRIDE;
459
+ }
460
+ // Build bone hierarchy
461
+ for (let i = 0; i < bones.length; i++) {
462
+ const parentIndex = parentIndices[i];
463
+ if (parentIndex >= 0 && parentIndex < bones.length && parentIndex !== i) {
464
+ bones[parentIndex].add(bones[i]);
465
+ }
466
+ }
467
+ // Set bones to bind pose for correct static display
468
+ for (let i = 0; i < bones.length; i++) {
469
+ const bone = bones[i];
470
+ const bindPose = bindPoses[i];
471
+ const parentIndex = parentIndices[i];
472
+ if (parentIndex < 0) {
473
+ const pos = new THREE.Vector3();
474
+ const quat = new THREE.Quaternion();
475
+ const scale = new THREE.Vector3();
476
+ bindPose.decompose(pos, quat, scale);
477
+ bone.position.copy(pos);
478
+ bone.quaternion.copy(quat);
479
+ bone.scale.set(1, 1, 1);
480
+ }
481
+ else {
482
+ const parentBindPose = bindPoses[parentIndex];
483
+ const parentInv = new THREE.Matrix4().copy(parentBindPose).invert();
484
+ const localMatrix = new THREE.Matrix4().multiplyMatrices(parentInv, bindPose);
485
+ const pos = new THREE.Vector3();
486
+ const quat = new THREE.Quaternion();
487
+ const scale = new THREE.Vector3();
488
+ localMatrix.decompose(pos, quat, scale);
489
+ bone.position.copy(pos);
490
+ bone.quaternion.copy(quat);
491
+ bone.scale.set(1, 1, 1);
492
+ }
493
+ bone.updateMatrix();
494
+ }
495
+ // Create Three.js materials with properties (same as static meshes)
496
+ const materials = [];
497
+ for (let i = 0; i < materialCount; i++) {
498
+ const mat = materialData[i];
499
+ const material = new THREE.MeshStandardMaterial({
500
+ side: THREE.DoubleSide,
501
+ name: mat.name || `Material_${i}`,
502
+ color: new THREE.Color(0.8, 0.8, 0.8) // Default gray
503
+ });
504
+ // Apply non-texture properties
505
+ for (const prop of mat.properties) {
506
+ const fieldName = prop.fieldName.toLowerCase();
507
+ // Handle color properties
508
+ if (fieldName.includes('color') || fieldName.includes('tint')) {
509
+ material.color.setRGB(prop.value[0], prop.value[1], prop.value[2]);
510
+ }
511
+ else if (fieldName === 'metallic') {
512
+ material.metalness = prop.value[0];
513
+ }
514
+ else if (fieldName === 'roughness') {
515
+ material.roughness = prop.value[0];
516
+ }
517
+ }
518
+ materials.push(material);
519
+ }
520
+ await this.loadMaterialTextures(materialData, materials);
521
+ // Collect root bones (hierarchy already built earlier)
522
+ const rootBones = bones.filter((_, index) => parentIndices[index] < 0);
523
+ // Create shared skeleton
524
+ const skeleton = new THREE.Skeleton(bones, boneMatrices);
525
+ // Pre-allocate combined buffers
526
+ const totalVertexCount = sortedGeometryInfos.reduce((sum, info) => sum + info.vertexCount, 0);
527
+ const totalIndexCount = sortedGeometryInfos.reduce((sum, info) => sum + info.indexCount, 0);
528
+ const combinedPositions = new Float32Array(totalVertexCount * 3);
529
+ const combinedNormals = new Float32Array(totalVertexCount * 3);
530
+ const combinedUVs = new Float32Array(totalVertexCount * 2);
531
+ const combinedSkinIndices = new Uint16Array(totalVertexCount * 4);
532
+ const combinedSkinWeights = new Float32Array(totalVertexCount * 4);
533
+ const combinedIndices = new Uint32Array(totalIndexCount);
534
+ const combinedGeometry = new THREE.BufferGeometry();
535
+ combinedGeometry.clearGroups();
536
+ let vertexCursor = 0; // counts vertices
537
+ let indexCursor = 0; // counts indices
538
+ for (const geomInfo of sortedGeometryInfos) {
539
+ const vertexStrideBytes = 32;
540
+ const vertexBase = vertexCursor; // base vertex index for this submesh
541
+ // Validate offsets before parsing
542
+ const requiredVertexBytes = geomInfo.vertexBufferOffset + geomInfo.vertexCount * vertexStrideBytes;
543
+ const requiredIndexBytes = geomInfo.indexBufferOffset + geomInfo.indexCount * 4;
544
+ const requiredWeightBytes = geomInfo.weightsOffset + geomInfo.vertexCount * 32;
545
+ if (requiredVertexBytes > data.byteLength) {
546
+ throw new Error(`Vertex buffer out of bounds: need ${requiredVertexBytes}, have ${data.byteLength}`);
547
+ }
548
+ if (requiredIndexBytes > data.byteLength) {
549
+ throw new Error(`Index buffer out of bounds: need ${requiredIndexBytes}, have ${data.byteLength}`);
550
+ }
551
+ if (requiredWeightBytes > data.byteLength) {
552
+ throw new Error(`Weight buffer out of bounds: need ${requiredWeightBytes}, have ${data.byteLength}`);
553
+ }
554
+ // Parse vertices
555
+ for (let v = 0; v < geomInfo.vertexCount; v++) {
556
+ const vertexOffset = geomInfo.vertexBufferOffset + v * vertexStrideBytes;
557
+ // positions
558
+ combinedPositions[(vertexCursor + v) * 3 + 0] = dataView.getFloat32(vertexOffset + 0, true);
559
+ combinedPositions[(vertexCursor + v) * 3 + 1] = dataView.getFloat32(vertexOffset + 4, true);
560
+ combinedPositions[(vertexCursor + v) * 3 + 2] = dataView.getFloat32(vertexOffset + 8, true);
561
+ // normals
562
+ if (geomInfo.hasNormals) {
563
+ combinedNormals[(vertexCursor + v) * 3 + 0] = dataView.getFloat32(vertexOffset + 12, true);
564
+ combinedNormals[(vertexCursor + v) * 3 + 1] = dataView.getFloat32(vertexOffset + 16, true);
565
+ combinedNormals[(vertexCursor + v) * 3 + 2] = dataView.getFloat32(vertexOffset + 20, true);
566
+ }
567
+ // uvs
568
+ if (geomInfo.hasUVs) {
569
+ combinedUVs[(vertexCursor + v) * 2 + 0] = dataView.getFloat32(vertexOffset + 24, true);
570
+ combinedUVs[(vertexCursor + v) * 2 + 1] = dataView.getFloat32(vertexOffset + 28, true);
571
+ }
572
+ // bone indices & weights
573
+ const weightOffset = geomInfo.weightsOffset + v * 32;
574
+ for (let j = 0; j < 4; j++) {
575
+ const boneIndex = dataView.getUint32(weightOffset + j * 4, true);
576
+ combinedSkinIndices[(vertexCursor + v) * 4 + j] = boneIndex < boneCount ? boneIndex : 0;
577
+ }
578
+ for (let j = 0; j < 4; j++) {
579
+ combinedSkinWeights[(vertexCursor + v) * 4 + j] = dataView.getFloat32(weightOffset + 16 + j * 4, true);
580
+ }
581
+ }
582
+ // Parse indices
583
+ const groupStart = indexCursor;
584
+ for (let i = 0; i < geomInfo.indexCount; i++) {
585
+ const indexValue = dataView.getUint32(geomInfo.indexBufferOffset + i * 4, true);
586
+ combinedIndices[indexCursor + i] = indexValue + vertexBase;
587
+ }
588
+ combinedGeometry.addGroup(groupStart, geomInfo.indexCount, Math.min(geomInfo.materialIndex, materials.length - 1));
589
+ vertexCursor += geomInfo.vertexCount;
590
+ indexCursor += geomInfo.indexCount;
591
+ }
592
+ combinedGeometry.setAttribute('position', new THREE.BufferAttribute(combinedPositions, 3));
593
+ if (geometryInfos.some(info => info.hasNormals)) {
594
+ combinedGeometry.setAttribute('normal', new THREE.BufferAttribute(combinedNormals, 3));
595
+ }
596
+ if (geometryInfos.some(info => info.hasUVs)) {
597
+ combinedGeometry.setAttribute('uv', new THREE.BufferAttribute(combinedUVs, 2));
598
+ }
599
+ combinedGeometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(combinedSkinIndices, 4));
600
+ combinedGeometry.setAttribute('skinWeight', new THREE.BufferAttribute(combinedSkinWeights, 4));
601
+ combinedGeometry.setIndex(new THREE.BufferAttribute(combinedIndices, 1));
602
+ combinedGeometry.computeBoundingBox();
603
+ combinedGeometry.computeBoundingSphere();
604
+ const skinnedMesh = new THREE.SkinnedMesh(combinedGeometry, materials);
605
+ // Create a separate bone container to preserve hierarchy
606
+ const boneContainer = new THREE.Group();
607
+ boneContainer.name = 'BoneContainer';
608
+ rootBones.forEach((root) => boneContainer.add(root));
609
+ // Bind skeleton with our provided inverse bind matrices
610
+ skinnedMesh.bind(skeleton, skinnedMesh.matrixWorld);
611
+ skinnedMesh.normalizeSkinWeights();
612
+ skinnedMesh.updateMatrixWorld(true);
613
+ const container = new THREE.Group();
614
+ container.name = stringId || 'SkinnedMesh';
615
+ container.add(boneContainer);
616
+ container.add(skinnedMesh);
617
+ container.userData.boneOriginalNames = boneOriginalNames;
618
+ container.userData.skinnedMesh = skinnedMesh;
619
+ return container;
620
+ }
309
621
  /**
310
622
  * Load a mesh by its canonical path/name
311
623
  */
@@ -403,10 +715,16 @@ class StowKitPack {
403
715
  return this.reader.readAssetMetadata(index);
404
716
  }
405
717
  /**
406
- * Parse texture metadata
718
+ * Load skinned mesh by index
407
719
  */
408
- parseTextureMetadata(metadataBytes) {
409
- return this.reader.parseTextureMetadata(metadataBytes);
720
+ async loadSkinnedMeshByIndex(index) {
721
+ const data = this.reader.readAssetData(index);
722
+ const metadata = this.reader.readAssetMetadata(index);
723
+ if (!data)
724
+ throw new Error(`Failed to read skinned mesh data for index ${index}`);
725
+ if (!metadata)
726
+ throw new Error(`No metadata for skinned mesh index ${index}`);
727
+ return await this.parseSkinnedMesh(metadata, data);
410
728
  }
411
729
  /**
412
730
  * Load mesh by index
@@ -448,56 +766,167 @@ class StowKitPack {
448
766
  return audio;
449
767
  }
450
768
  /**
451
- * Get audio metadata by path
769
+ * Get audio metadata by path (uses WASM parsing)
452
770
  */
453
771
  getAudioMetadata(assetPath) {
454
772
  const assetIndex = this.reader.findAssetByPath(assetPath);
455
773
  if (assetIndex < 0)
456
774
  return null;
457
- const metadata = this.reader.readAssetMetadata(assetIndex);
458
- if (!metadata || metadata.length < 140)
459
- return null;
460
- const view = new DataView(metadata.buffer, metadata.byteOffset, metadata.byteLength);
461
- const decoder = new TextDecoder();
462
- const stringIdBytes = metadata.slice(0, 128);
463
- const nullIdx = stringIdBytes.indexOf(0);
464
- const stringId = decoder.decode(stringIdBytes.slice(0, nullIdx >= 0 ? nullIdx : 128));
465
- return {
466
- stringId,
467
- sampleRate: view.getUint32(128, true),
468
- channels: view.getUint32(132, true),
469
- durationMs: view.getUint32(136, true)
470
- };
775
+ return this.reader.parseAudioMetadata(assetIndex);
471
776
  }
472
777
  /**
473
- * Load material schema by index
778
+ * Get animation metadata by index (uses WASM parsing)
474
779
  */
475
- loadMaterialSchemaByIndex(index) {
476
- return this.loadMaterialSchemaByIndex_internal(index);
780
+ getAnimationMetadata(index) {
781
+ return this.reader.parseAnimationMetadata(index);
477
782
  }
478
783
  /**
479
- * Get mesh materials by index
784
+ * Get texture metadata by index (uses WASM parsing)
480
785
  */
481
- getMeshMaterialsByIndex(index) {
482
- return this.getMeshMaterialsByIndex_internal(index);
786
+ getTextureMetadata(index) {
787
+ return this.reader.parseTextureMetadata(index);
483
788
  }
484
789
  /**
485
- * Get material schema information
790
+ * Load animation clip by string ID
486
791
  */
487
- getMaterialSchema(assetPath) {
488
- const assetIndex = this.reader.findAssetByPath(assetPath);
489
- if (assetIndex < 0)
490
- return null;
491
- return this.loadMaterialSchemaByIndex_internal(assetIndex);
792
+ async loadAnimationClip(stringId) {
793
+ const index = this.reader.findAssetByPath(stringId);
794
+ if (index < 0) {
795
+ throw new Error(`Animation clip not found: ${stringId}`);
796
+ }
797
+ return this.loadAnimationClipByIndex(index);
492
798
  }
493
799
  /**
494
- * Get mesh materials information
800
+ * Load and play animation on a skinned mesh group
495
801
  */
496
- getMeshMaterials(assetPath) {
497
- const assetIndex = this.reader.findAssetByPath(assetPath);
498
- if (assetIndex < 0)
499
- return null;
500
- return this.getMeshMaterialsByIndex_internal(assetIndex);
802
+ async loadAnimation(skinnedMeshGroup, animationPath) {
803
+ const clip = await this.loadAnimationClip(animationPath);
804
+ const mixer = new THREE.AnimationMixer(skinnedMeshGroup);
805
+ const action = mixer.clipAction(clip);
806
+ action.setLoop(THREE.LoopRepeat, Infinity);
807
+ action.play();
808
+ return { mixer, action, clip };
809
+ }
810
+ /**
811
+ * Load and play animation by index
812
+ */
813
+ async loadAnimationByIndex(skinnedMeshGroup, animationIndex) {
814
+ const clip = await this.loadAnimationClipByIndex(animationIndex);
815
+ const mixer = new THREE.AnimationMixer(skinnedMeshGroup);
816
+ const action = mixer.clipAction(clip);
817
+ action.setLoop(THREE.LoopRepeat, Infinity);
818
+ action.play();
819
+ return { mixer, action, clip };
820
+ }
821
+ /**
822
+ * Load animation clip by index
823
+ */
824
+ async loadAnimationClipByIndex(index) {
825
+ const metadata = this.reader.readAssetMetadata(index);
826
+ const data = this.reader.readAssetData(index);
827
+ if (!metadata || !data) {
828
+ throw new Error('Failed to read animation clip data');
829
+ }
830
+ return this.parseAnimationClip(metadata, data);
831
+ }
832
+ /**
833
+ * Parse animation clip from binary data
834
+ */
835
+ parseAnimationClip(metadata, data) {
836
+ const view = new DataView(metadata.buffer, metadata.byteOffset, metadata.byteLength);
837
+ const decoder = new TextDecoder();
838
+ // Parse AnimationClipMetadata (272 bytes total)
839
+ const stringIdBytes = metadata.slice(0, 128);
840
+ const stringIdNullIndex = stringIdBytes.indexOf(0);
841
+ const stringId = decoder.decode(stringIdBytes.slice(0, stringIdNullIndex >= 0 ? stringIdNullIndex : 128));
842
+ const targetMeshIdBytes = metadata.slice(128, 256);
843
+ const targetMeshIdNullIndex = targetMeshIdBytes.indexOf(0);
844
+ decoder
845
+ .decode(targetMeshIdBytes.slice(0, targetMeshIdNullIndex >= 0 ? targetMeshIdNullIndex : 128))
846
+ .trim();
847
+ const durationSeconds = view.getFloat32(256, true); // Duration in seconds (per C header)
848
+ view.getFloat32(260, true); // For custom playback rate if needed
849
+ const channelCount = view.getUint32(264, true);
850
+ const boneCount = view.getUint32(268, true);
851
+ // Parse AnimationChannel[] (starts at offset 272, each is 28 bytes)
852
+ const channelsOffset = 272;
853
+ const channels = [];
854
+ for (let i = 0; i < channelCount; i++) {
855
+ const chOffset = channelsOffset + i * 28;
856
+ channels.push({
857
+ boneIndex: view.getUint32(chOffset, true),
858
+ keyframeType: view.getUint32(chOffset + 4, true),
859
+ keyframeCount: view.getUint32(chOffset + 8, true),
860
+ keyframeTimesOffset: Number(view.getBigUint64(chOffset + 12, true)),
861
+ keyframeValuesOffset: Number(view.getBigUint64(chOffset + 20, true))
862
+ });
863
+ }
864
+ // Parse AnimationBoneInfo[] (starts after all channels, each is 68 bytes)
865
+ const bonesOffset = channelsOffset + channelCount * 28;
866
+ const bones = [];
867
+ for (let i = 0; i < boneCount; i++) {
868
+ const boneOffset = bonesOffset + i * 68;
869
+ const nameBytes = metadata.slice(boneOffset, boneOffset + 64);
870
+ const nullIndex = nameBytes.indexOf(0);
871
+ const rawName = decoder
872
+ .decode(nameBytes.slice(0, nullIndex >= 0 ? nullIndex : 64))
873
+ .trim();
874
+ const sanitizedName = this.sanitizeTrackName(rawName);
875
+ const parentIndex = view.getInt32(boneOffset + 64, true);
876
+ bones.push({ name: rawName, parentIndex, sanitizedName });
877
+ }
878
+ // Create DataView for keyframe data
879
+ const dataView = new DataView(data.buffer, data.byteOffset, data.byteLength);
880
+ // Create Three.js KeyframeTracks
881
+ const tracks = [];
882
+ let maxKeyframeTime = 0;
883
+ // Process channels - each channel specifies which bone it animates via boneIndex
884
+ for (let i = 0; i < channelCount; i++) {
885
+ const channel = channels[i];
886
+ // Validate bone index
887
+ if (channel.boneIndex >= boneCount) {
888
+ console.warn(`[AnimationClip] Channel ${i} references invalid bone index ${channel.boneIndex} (have ${boneCount} bones)`);
889
+ continue;
890
+ }
891
+ const boneName = bones[channel.boneIndex].sanitizedName;
892
+ // Skip scale tracks - bones should always have unit scale
893
+ if (channel.keyframeType === 2) {
894
+ continue;
895
+ }
896
+ // Read times (already in seconds per C header)
897
+ const times = this.readFloatArray(dataView, channel.keyframeTimesOffset, channel.keyframeCount);
898
+ // Track the maximum keyframe time to calculate actual duration
899
+ if (times.length > 0) {
900
+ const channelMaxTime = times[times.length - 1];
901
+ if (channelMaxTime > maxKeyframeTime) {
902
+ maxKeyframeTime = channelMaxTime;
903
+ }
904
+ }
905
+ // Create track based on keyframe type
906
+ if (channel.keyframeType === 0) {
907
+ // Position (vec3)
908
+ const values = this.readFloatArray(dataView, channel.keyframeValuesOffset, channel.keyframeCount * 3);
909
+ tracks.push(new THREE.VectorKeyframeTrack(`${boneName}.position`, times, values));
910
+ }
911
+ else if (channel.keyframeType === 1) {
912
+ // Rotation (quaternion)
913
+ const values = this.readFloatArray(dataView, channel.keyframeValuesOffset, channel.keyframeCount * 4);
914
+ tracks.push(new THREE.QuaternionKeyframeTrack(`${boneName}.quaternion`, times, values));
915
+ }
916
+ }
917
+ // Use the actual maximum keyframe time as duration instead of metadata duration
918
+ const actualDuration = maxKeyframeTime > 0 ? maxKeyframeTime : durationSeconds;
919
+ return new THREE.AnimationClip(stringId, actualDuration, tracks);
920
+ }
921
+ /**
922
+ * Helper to read float array from DataView
923
+ */
924
+ readFloatArray(view, offset, count) {
925
+ const result = new Float32Array(count);
926
+ for (let i = 0; i < count; i++) {
927
+ result[i] = view.getFloat32(offset + i * 4, true);
928
+ }
929
+ return result;
501
930
  }
502
931
  /**
503
932
  * Close the pack and free resources
@@ -511,13 +940,15 @@ class StowKitPack {
511
940
  const matData = materialData[i];
512
941
  const material = materials[i];
513
942
  for (const prop of matData.properties) {
514
- if (prop.textureId && prop.textureId.length > 0) {
943
+ // Trim and check for truly non-empty texture ID
944
+ const textureId = prop.textureId?.trim();
945
+ if (textureId && textureId.length > 0) {
515
946
  try {
516
- const texture = await this.loadTexture(prop.textureId);
947
+ const texture = await this.loadTexture(textureId);
517
948
  this.applyTextureToMaterial(material, prop.fieldName, texture);
518
949
  }
519
950
  catch (error) {
520
- console.error(`Failed to load texture "${prop.textureId}":`, error);
951
+ console.error(`Failed to load texture "${textureId}":`, error);
521
952
  }
522
953
  }
523
954
  }
@@ -564,88 +995,11 @@ class StowKitPack {
564
995
  }
565
996
  material.needsUpdate = true;
566
997
  }
567
- loadMaterialSchemaByIndex_internal(index) {
568
- const metadata = this.reader.readAssetMetadata(index);
569
- if (!metadata || metadata.length < 196)
570
- return null;
571
- const view = new DataView(metadata.buffer, metadata.byteOffset, metadata.byteLength);
572
- const decoder = new TextDecoder();
573
- const stringIdBytes = metadata.slice(0, 128);
574
- const schemaNameBytes = metadata.slice(128, 192);
575
- const stringId = decoder.decode(stringIdBytes.slice(0, stringIdBytes.indexOf(0) || 128));
576
- const schemaName = decoder.decode(schemaNameBytes.slice(0, schemaNameBytes.indexOf(0) || 64));
577
- const fieldCount = view.getUint32(192, true);
578
- const fields = [];
579
- let offset = 196;
580
- for (let i = 0; i < fieldCount; i++) {
581
- if (offset + 88 > metadata.length)
582
- break;
583
- const fieldNameBytes = metadata.slice(offset, offset + 64);
584
- const fieldName = decoder.decode(fieldNameBytes.slice(0, fieldNameBytes.indexOf(0) || 64));
585
- const fieldType = view.getUint32(offset + 64, true);
586
- const previewFlags = view.getUint32(offset + 68, true);
587
- const defaultValue = [
588
- view.getFloat32(offset + 72, true),
589
- view.getFloat32(offset + 76, true),
590
- view.getFloat32(offset + 80, true),
591
- view.getFloat32(offset + 84, true)
592
- ];
593
- const typeNames = ['Texture', 'Color', 'Float', 'Vec2', 'Vec3', 'Vec4', 'Int'];
594
- const previewFlagNames = ['None', 'MainTex', 'Tint'];
595
- fields.push({
596
- name: fieldName,
597
- type: typeNames[fieldType] || 'Float',
598
- previewFlag: previewFlagNames[previewFlags] || 'None',
599
- defaultValue
600
- });
601
- offset += 88;
602
- }
603
- return { stringId, schemaName, fields };
604
- }
605
- getMeshMaterialsByIndex_internal(index) {
606
- const metadata = this.reader.readAssetMetadata(index);
607
- if (!metadata || metadata.length < 140)
608
- return null;
609
- const view = new DataView(metadata.buffer, metadata.byteOffset, metadata.byteLength);
610
- const decoder = new TextDecoder();
611
- const meshGeometryCount = view.getUint32(0, true);
612
- const materialCount = view.getUint32(4, true);
613
- const nodeCount = view.getUint32(8, true);
614
- let offset = 140;
615
- offset += meshGeometryCount * 40;
616
- const materials = [];
617
- for (let i = 0; i < materialCount; i++) {
618
- if (offset + 196 > metadata.length)
619
- break;
620
- const nameBytes = metadata.slice(offset, offset + 64);
621
- const schemaIdBytes = metadata.slice(offset + 64, offset + 192);
622
- const name = decoder.decode(nameBytes.slice(0, nameBytes.indexOf(0) || 64));
623
- const schemaId = decoder.decode(schemaIdBytes.slice(0, schemaIdBytes.indexOf(0) || 128));
624
- const propertyCount = view.getUint32(offset + 192, true);
625
- materials.push({ name, schemaId, propertyCount, properties: [] });
626
- offset += 196;
627
- }
628
- offset += nodeCount * 116;
629
- offset += nodeCount * 4;
630
- for (const mat of materials) {
631
- for (let j = 0; j < mat.propertyCount; j++) {
632
- if (offset + 144 > metadata.length)
633
- break;
634
- const fieldNameBytes = metadata.slice(offset, offset + 64);
635
- const fieldName = decoder.decode(fieldNameBytes.slice(0, fieldNameBytes.indexOf(0) || 64));
636
- const value = [
637
- view.getFloat32(offset + 64, true),
638
- view.getFloat32(offset + 68, true),
639
- view.getFloat32(offset + 72, true),
640
- view.getFloat32(offset + 76, true)
641
- ];
642
- const textureIdBytes = metadata.slice(offset + 80, offset + 144);
643
- const textureId = decoder.decode(textureIdBytes.slice(0, textureIdBytes.indexOf(0) || 64));
644
- mat.properties.push({ fieldName, value, textureId });
645
- offset += 144;
646
- }
647
- }
648
- return materials;
998
+ sanitizeTrackName(name) {
999
+ return name
1000
+ .trim()
1001
+ .replace(/[^A-Za-z0-9_\-]+/g, '_')
1002
+ .replace(/_{2,}/g, '_');
649
1003
  }
650
1004
  }
651
1005
 
@@ -693,7 +1047,10 @@ class StowKitLoader {
693
1047
  if (data instanceof ArrayBuffer) {
694
1048
  arrayBuffer = data;
695
1049
  }
696
- else if (data instanceof Blob || data instanceof File) {
1050
+ else if (typeof Blob !== 'undefined' && data instanceof Blob) {
1051
+ arrayBuffer = await data.arrayBuffer();
1052
+ }
1053
+ else if ('arrayBuffer' in data && typeof data.arrayBuffer === 'function') {
697
1054
  arrayBuffer = await data.arrayBuffer();
698
1055
  }
699
1056
  else {