@aura3d/assets 1.1.0 → 1.3.1

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/src/GLTFLoader.ts CHANGED
@@ -387,6 +387,8 @@ export interface GLTFRuntimeMaterialKey {
387
387
  }
388
388
 
389
389
  export interface GLTFMorphTargetAsset {
390
+ /** Morph target / blendshape name (from `mesh.extras.targetNames`), when authored. */
391
+ readonly name?: string;
390
392
  readonly positions: readonly (readonly [number, number, number])[];
391
393
  readonly normals: readonly (readonly [number, number, number])[];
392
394
  readonly tangents: readonly (readonly [number, number, number])[];
@@ -842,13 +844,15 @@ export class GLTFLoader implements AssetLoader<GLTFAsset> {
842
844
  const typedTangents = tangents.map((tangent) => [tangent[0] ?? 0, tangent[1] ?? 0, tangent[2] ?? 0, tangent[3] ?? 1] as const);
843
845
  const typedColors = colors.map((color) => [color[0] ?? 1, color[1] ?? 1, color[2] ?? 1, color[3] ?? 1] as const);
844
846
  const typedJoints = joints.map((joint) => [joint[0] ?? 0, joint[1] ?? 0, joint[2] ?? 0, joint[3] ?? 0] as const);
847
+ const weightNormalization: SkinWeightNormalizationStats = { degenerate: 0, rescaled: 0 };
845
848
  const typedWeights = weights.map((weight) => normalizeSkinWeights([
846
849
  weight[0] ?? 0,
847
850
  weight[1] ?? 0,
848
851
  weight[2] ?? 0,
849
852
  weight[3] ?? 0
850
- ], meshIndex, primitiveIndex));
851
- const morphTargets = readMorphTargets(json, buffers, primitive, positions.length, meshIndex, primitiveIndex, accessorCache);
853
+ ], weightNormalization));
854
+ reportSkinWeightNormalization(meshIndex, primitiveIndex, weightNormalization);
855
+ const morphTargets = readMorphTargets(json, buffers, primitive, positions.length, meshIndex, primitiveIndex, accessorCache, readMorphTargetNames(mesh));
852
856
  const resolvedPrimitive = resolvePrimitiveMode(primitive, indices?.map((index) => index[0] ?? 0), positions.length, meshIndex, primitiveIndex);
853
857
  const geometry = createGeometryAsset(typedPositions, resolvedPrimitive.indices);
854
858
  return {
@@ -1459,7 +1463,8 @@ function readMorphTargets(
1459
1463
  vertexCount: number,
1460
1464
  meshIndex: number,
1461
1465
  primitiveIndex: number,
1462
- accessorCache: GLTFAccessorReadCache
1466
+ accessorCache: GLTFAccessorReadCache,
1467
+ targetNames: readonly string[] = []
1463
1468
  ): readonly GLTFMorphTargetAsset[] {
1464
1469
  if (primitive.targets === undefined) {
1465
1470
  return [];
@@ -1481,7 +1486,9 @@ function readMorphTargets(
1481
1486
  if (tangents.length > 0 && tangents.length !== vertexCount) {
1482
1487
  throw new Error(`glTF mesh ${meshIndex} primitive ${primitiveIndex} morph target ${targetIndex} TANGENT count mismatch`);
1483
1488
  }
1489
+ const name = targetNames[targetIndex];
1484
1490
  return {
1491
+ ...(typeof name === "string" && name.length > 0 ? { name } : {}),
1485
1492
  positions: positions.map((position) => [position[0] ?? 0, position[1] ?? 0, position[2] ?? 0] as const),
1486
1493
  normals: normals.map((normal) => [normal[0] ?? 0, normal[1] ?? 0, normal[2] ?? 0] as const),
1487
1494
  tangents: tangents.map((tangent) => [tangent[0] ?? 0, tangent[1] ?? 0, tangent[2] ?? 0] as const)
@@ -1489,6 +1496,17 @@ function readMorphTargets(
1489
1496
  });
1490
1497
  }
1491
1498
 
1499
+ // glTF stores morph-target names in `mesh.extras.targetNames` (a parallel array). Read them so the
1500
+ // runtime can expose named morph influences instead of bare indices.
1501
+ function readMorphTargetNames(mesh: GLTFMesh): readonly string[] {
1502
+ const extras = (mesh as { extras?: Record<string, unknown> }).extras;
1503
+ const names = extras?.targetNames;
1504
+ if (Array.isArray(names) && names.every((n) => typeof n === "string")) {
1505
+ return names as readonly string[];
1506
+ }
1507
+ return [];
1508
+ }
1509
+
1492
1510
  function validateMorphTargetDescriptor(
1493
1511
  target: Readonly<Record<string, number>>,
1494
1512
  meshIndex: number,
@@ -1866,27 +1884,67 @@ function validateQuantizedAttributeComponentType(
1866
1884
  );
1867
1885
  }
1868
1886
 
1869
- function normalizeSkinWeights(
1887
+ export interface SkinWeightNormalizationStats {
1888
+ degenerate: number;
1889
+ rescaled: number;
1890
+ }
1891
+
1892
+ const SKIN_WEIGHT_DEGENERATE_EPSILON = 1e-6;
1893
+
1894
+ /**
1895
+ * Normalize a single vertex's 4 skin weights so they sum to 1.
1896
+ *
1897
+ * Real-world catalog GLBs (Objaverse/Sketchfab) frequently ship denormalized,
1898
+ * negative, or all-zero WEIGHTS_0 values. Rather than hard-failing the whole
1899
+ * asset, sanitize per vertex: negative/non-finite components are clamped to 0,
1900
+ * a degenerate (near-zero) sum collapses to a rigid binding `[1, 0, 0, 0]`, and
1901
+ * any other sum is rescaled to 1. The optional `stats` accumulator lets callers
1902
+ * surface a single diagnostic per primitive instead of one per vertex.
1903
+ *
1904
+ * Component proportions are preserved regardless of the source accessor
1905
+ * componentType (FLOAT 5126, or normalized UNSIGNED_BYTE 5121 / UNSIGNED_SHORT
1906
+ * 5123), so integer-encoded weights normalize correctly without extra handling.
1907
+ */
1908
+ export function normalizeSkinWeights(
1870
1909
  weights: readonly [number, number, number, number],
1871
- meshIndex: number,
1872
- primitiveIndex: number
1910
+ stats?: SkinWeightNormalizationStats
1873
1911
  ): readonly [number, number, number, number] {
1874
- if (weights.some((weight) => !Number.isFinite(weight) || weight < 0)) {
1875
- throw new Error(`glTF mesh ${meshIndex} primitive ${primitiveIndex} WEIGHTS_0 must contain finite non-negative values`);
1912
+ const sanitized: [number, number, number, number] = [
1913
+ Number.isFinite(weights[0]) && weights[0] > 0 ? weights[0] : 0,
1914
+ Number.isFinite(weights[1]) && weights[1] > 0 ? weights[1] : 0,
1915
+ Number.isFinite(weights[2]) && weights[2] > 0 ? weights[2] : 0,
1916
+ Number.isFinite(weights[3]) && weights[3] > 0 ? weights[3] : 0
1917
+ ];
1918
+ const sum = sanitized[0] + sanitized[1] + sanitized[2] + sanitized[3];
1919
+ if (sum < SKIN_WEIGHT_DEGENERATE_EPSILON) {
1920
+ if (stats) stats.degenerate += 1;
1921
+ return [1, 0, 0, 0] as const;
1876
1922
  }
1877
- const sum = weights[0] + weights[1] + weights[2] + weights[3];
1878
- if (sum <= 0) {
1879
- throw new Error(`glTF mesh ${meshIndex} primitive ${primitiveIndex} WEIGHTS_0 must sum to a positive value`);
1923
+ if (Math.abs(sum - 1) <= 1e-5) {
1924
+ return sanitized as readonly [number, number, number, number];
1880
1925
  }
1881
- if (Math.abs(sum - 1) <= 1e-5) return weights;
1926
+ if (stats) stats.rescaled += 1;
1882
1927
  return [
1883
- weights[0] / sum,
1884
- weights[1] / sum,
1885
- weights[2] / sum,
1886
- weights[3] / sum
1928
+ sanitized[0] / sum,
1929
+ sanitized[1] / sum,
1930
+ sanitized[2] / sum,
1931
+ sanitized[3] / sum
1887
1932
  ] as const;
1888
1933
  }
1889
1934
 
1935
+ function reportSkinWeightNormalization(
1936
+ meshIndex: number,
1937
+ primitiveIndex: number,
1938
+ stats: SkinWeightNormalizationStats
1939
+ ): void {
1940
+ if (stats.degenerate === 0 && stats.rescaled === 0) return;
1941
+ if (typeof console === "undefined" || typeof console.warn !== "function") return;
1942
+ console.warn(
1943
+ `glTF mesh ${meshIndex} primitive ${primitiveIndex} WEIGHTS_0 normalized: ` +
1944
+ `${stats.degenerate} degenerate vertex(es) set to [1,0,0,0], ${stats.rescaled} rescaled to sum 1`
1945
+ );
1946
+ }
1947
+
1890
1948
  function createCameraAssets(json: GLTFJson): readonly GLTFCameraAsset[] {
1891
1949
  return (json.cameras ?? []).map((camera, index) => {
1892
1950
  if (camera.type === "perspective") {
@@ -80,6 +80,13 @@ export interface GLTFRenderResourceOptions {
80
80
  readonly sceneIndex?: GLTFSceneCreateOptions["sceneIndex"];
81
81
  readonly sceneName?: GLTFSceneCreateOptions["sceneName"];
82
82
  readonly materialRenderStateOverrides?: readonly GLTFMaterialRenderStateOverride[];
83
+ /**
84
+ * When `true`, a texture that fails to fetch/decode aborts the whole asset load
85
+ * (legacy behavior). When omitted/`false` (default), the loader logs a warning
86
+ * and substitutes a neutral 1x1 texture so the material falls back to its
87
+ * `baseColorFactor` and the rest of the asset still loads.
88
+ */
89
+ readonly failOnMissingTexture?: boolean;
83
90
  }
84
91
 
85
92
  export interface GLTFMaterialRenderStateOverride {
@@ -392,7 +399,16 @@ export async function createGLTFRenderResources(
392
399
  return texture;
393
400
  } catch (error) {
394
401
  textureByImage.delete(cacheKey);
395
- throw error;
402
+ if (options.failOnMissingTexture) {
403
+ throw error;
404
+ }
405
+ const reason = error instanceof Error ? error.message : String(error);
406
+ warnMissingGLTFTexture(textureAsset.name, image.uri, reason);
407
+ const fallback = createFallbackTexture(textureAsset.name, colorSpace);
408
+ const fallbackPromise = Promise.resolve(fallback);
409
+ textureByImage.set(cacheKey, fallbackPromise);
410
+ setTextureLibraryEntry(textureLibrary, textureAsset.name, colorSpace, fallback);
411
+ return fallback;
396
412
  }
397
413
  };
398
414
 
@@ -1534,7 +1550,15 @@ function createDefaultGLTFMaterial(mesh: GLTFMeshAsset, options: { readonly inst
1534
1550
  return new PBRMaterial(defaults);
1535
1551
  }
1536
1552
 
1537
- function renderStateForGLTFMaterial(
1553
+ /**
1554
+ * baseColorFactor alpha at or above this value is treated as fully opaque when
1555
+ * deciding whether a `BLEND` material is a spurious-blend (ghost) case. 8-bit
1556
+ * authoring rounds 255/255 to 1.0 but tools occasionally emit 0.996 (254/255),
1557
+ * so we allow a small epsilon while still catching genuine fades.
1558
+ */
1559
+ const BLEND_OPAQUE_ALPHA_THRESHOLD = 0.996;
1560
+
1561
+ export function renderStateForGLTFMaterial(
1538
1562
  material: GLTFMaterialAsset,
1539
1563
  overrides: readonly GLTFMaterialRenderStateOverride[] = []
1540
1564
  ): Partial<RenderState> {
@@ -1550,9 +1574,40 @@ function renderStateForGLTFMaterial(
1550
1574
  }
1551
1575
 
1552
1576
  function requiresTransparentRenderState(material: GLTFMaterialAsset): boolean {
1553
- if (material.alphaMode === "BLEND") return true;
1577
+ if (material.alphaMode === "BLEND") return !isEffectivelyOpaqueBlendMaterial(material);
1554
1578
  if (material.alphaMode !== "OPAQUE") return false;
1555
1579
  if (usesUnbackedScalarTransmission(material)) return false;
1580
+ return materialHasTransmissionOrVolume(material);
1581
+ }
1582
+
1583
+ /**
1584
+ * Many catalog GLBs (notably Sketchfab exports) spuriously tag fully opaque
1585
+ * materials with `alphaMode: "BLEND"`. The standard glTF blend equation over a
1586
+ * fully opaque surface (baseColorFactor alpha == 1, no texel alpha in use) is a
1587
+ * no-op, but enabling blend disables depth writes — so those characters render
1588
+ * as translucent "ghosts" with broken depth sorting.
1589
+ *
1590
+ * When a BLEND material is provably opaque we treat it as OPAQUE (blend off,
1591
+ * depthWrite on). The check is deliberately conservative: anything that could
1592
+ * carry real transparency — alpha factor < 1, transmission/volume, or a
1593
+ * meaningful alphaCutoff — keeps blending.
1594
+ *
1595
+ * Known edge case: a baseColorTexture's own alpha channel cannot be inspected
1596
+ * here (images are decoded asynchronously and channel-usage is not tracked on
1597
+ * the material), so a BLEND material that relies purely on per-texel texture
1598
+ * alpha while keeping baseColorFactor alpha == 1 would be flattened to opaque.
1599
+ * That pattern is rare in the wild (such assets normally use MASK or set the
1600
+ * factor alpha < 1), and the override path (renderStateOverrides /
1601
+ * GLTFMaterialRenderStateOverride) remains available to force blend back on.
1602
+ */
1603
+ function isEffectivelyOpaqueBlendMaterial(material: GLTFMaterialAsset): boolean {
1604
+ if (material.alphaMode !== "BLEND") return false;
1605
+ if (material.baseColorFactor[3] < BLEND_OPAQUE_ALPHA_THRESHOLD) return false;
1606
+ if (materialHasTransmissionOrVolume(material)) return false;
1607
+ return true;
1608
+ }
1609
+
1610
+ function materialHasTransmissionOrVolume(material: GLTFMaterialAsset): boolean {
1556
1611
  return (material.transmission?.factor ?? 0) > 0.001
1557
1612
  || (material.diffuseTransmission?.factor ?? 0) > 0.001
1558
1613
  || (material.volume?.thicknessFactor ?? 0) > 0.001
@@ -2096,10 +2151,54 @@ function isKTX2BasisImage(image: GLTFImageAsset): boolean {
2096
2151
  return image.mimeType === "image/ktx2" || /\.ktx2(?:[?#]|$)/i.test(image.uri ?? "");
2097
2152
  }
2098
2153
 
2154
+ function createFallbackTexture(label: string, colorSpace: GLTFTextureColorSpace): Texture {
2155
+ return new Texture({
2156
+ width: 1,
2157
+ height: 1,
2158
+ colorSpace,
2159
+ label: `${label} (missing-texture-fallback)`,
2160
+ data: new Uint8Array([255, 255, 255, 255])
2161
+ });
2162
+ }
2163
+
2164
+ function warnMissingGLTFTexture(textureName: string, imageUri: string | undefined, reason: string): void {
2165
+ if (typeof console === "undefined" || typeof console.warn !== "function") return;
2166
+ const source = imageUri ? ` (${imageUri})` : "";
2167
+ console.warn(
2168
+ `glTF texture "${textureName}"${source} could not be loaded; falling back to baseColorFactor. Reason: ${reason}`
2169
+ );
2170
+ }
2171
+
2099
2172
  function resolveImageUrl(assetUrl: string, imageUri: string): string {
2100
2173
  if (/^(?:data:|blob:|https?:|file:)/i.test(imageUri)) return imageUri;
2101
2174
  if (assetUrl.startsWith("data:")) {
2102
2175
  throw new Error(`Relative glTF image uri ${imageUri} cannot be resolved from a data URL asset`);
2103
2176
  }
2104
- return new URL(imageUri, assetUrl).toString();
2177
+ return new URL(imageUri, resolveAbsoluteAssetBase(assetUrl)).toString();
2178
+ }
2179
+
2180
+ /**
2181
+ * Derive an absolute base URL for resolving relative texture URIs.
2182
+ *
2183
+ * Catalog assets are often loaded from root-relative paths (e.g.
2184
+ * `/aura-assets/x.glb`). `new URL(uri, base)` requires `base` to be absolute, so
2185
+ * a bare relative `assetUrl` throws `Failed to construct 'URL': Invalid base URL`.
2186
+ * If the asset URL is already absolute we use it directly; otherwise we resolve it
2187
+ * against the document/origin base when running in a browser, falling back to a
2188
+ * synthetic `file:///` origin so resolution never throws in non-browser contexts.
2189
+ */
2190
+ function resolveAbsoluteAssetBase(assetUrl: string): string {
2191
+ if (/^[a-z][a-z0-9+.-]*:/i.test(assetUrl)) return assetUrl;
2192
+ const documentBase =
2193
+ (typeof document !== "undefined" && document.baseURI) ||
2194
+ (typeof location !== "undefined" && location.href) ||
2195
+ undefined;
2196
+ if (documentBase) {
2197
+ try {
2198
+ return new URL(assetUrl, documentBase).toString();
2199
+ } catch {
2200
+ // fall through to synthetic base below
2201
+ }
2202
+ }
2203
+ return new URL(assetUrl, "file:///").toString();
2105
2204
  }
@@ -25,7 +25,9 @@ export type {
25
25
  GLTFSceneAnalysisEvidence,
26
26
  GLTFSceneAnalysisOptions
27
27
  } from "./SceneAnalysisFixtures";
28
- export { GLTFLoader } from "./GLTFLoader";
28
+ export { GLTFLoader, normalizeSkinWeights } from "./GLTFLoader";
29
+ export { autoFitGLTFScene, computeAutoFitTransform } from "./GLTFAutoFit";
30
+ export type { AutoFitOptions, AutoFitTransform, GLTFUpAxis } from "./GLTFAutoFit";
29
31
  export type {
30
32
  GLTFAsset,
31
33
  GLTFCameraAsset,
@@ -57,7 +59,8 @@ export type {
57
59
  GLTFTextureAsset,
58
60
  GLTFTransmissionMaterialExtension,
59
61
  GLTFVolumeMaterialExtension,
60
- SerializedGLTFAsset
62
+ SerializedGLTFAsset,
63
+ SkinWeightNormalizationStats
61
64
  } from "./GLTFLoader";
62
65
  export { inspectGLTFAsset } from "./AssetInspection";
63
66
  export type {
@@ -89,7 +92,7 @@ export type {
89
92
  GLTFExtensionSupportFamily,
90
93
  GLTFExtensionSupportStatus
91
94
  } from "./GLTFExtensionSupport";
92
- export { createGLTFSceneAnimationMixer, createGLTFSceneAnimationRuntime, GLTFImportedSkeletonIKController, GLTFSceneAnimationCloneSampler, GLTFSceneAnimationMixerBinding, GLTFSceneAnimationRuntime, GLTFSceneMorphTargetController } from "./GLTFAnimationRuntime";
95
+ export { createGLTFSceneAnimationMixer, createGLTFSceneAnimationRuntime, GLTFImportedSkeletonIKController, GLTFSceneAnimationCloneSampler, GLTFSceneAnimationMixerBinding, GLTFSceneAnimationRuntime, GLTFSceneMorphTargetController, resolveGLTFClipName } from "./GLTFAnimationRuntime";
93
96
  export type {
94
97
  GLTFImportedSkeletonIKControllerOptions,
95
98
  GLTFImportedSkeletonIKControllerSnapshot,
@@ -108,7 +111,9 @@ export type {
108
111
  GLTFSceneAnimationRuntimeOptions,
109
112
  GLTFSceneAnimationRuntimeSnapshot,
110
113
  GLTFSceneMorphTargetControllerOptions,
111
- GLTFSceneMorphTargetControllerSnapshot
114
+ GLTFSceneMorphTargetControllerSnapshot,
115
+ GLTFScenePose,
116
+ GLTFScenePoseBoneTransform
112
117
  } from "./GLTFAnimationRuntime";
113
118
  export {
114
119
  DEFAULT_GLTF_RENDER_ENVIRONMENT_LIGHTING,
package/src/index.ts CHANGED
@@ -49,13 +49,15 @@ export type {
49
49
  GLTFExtensionSupportFamily,
50
50
  GLTFExtensionSupportStatus
51
51
  } from "./GLTFExtensionSupport";
52
- export { createGLTFSceneAnimationMixer, createGLTFSceneAnimationRuntime, GLTFImportedSkeletonIKController, GLTFSceneAnimationCloneSampler, GLTFSceneAnimationMixerBinding, GLTFSceneAnimationRuntime, GLTFSceneMorphTargetController } from "./GLTFAnimationRuntime";
52
+ export { createGLTFSceneAnimationMixer, createGLTFSceneAnimationRuntime, GLTFImportedSkeletonIKController, GLTFSceneAnimationCloneSampler, GLTFSceneAnimationMixerBinding, GLTFSceneAnimationRuntime, GLTFSceneMorphTargetController, resolveGLTFClipName } from "./GLTFAnimationRuntime";
53
53
  export type {
54
54
  GLTFImportedSkeletonIKControllerOptions,
55
55
  GLTFImportedSkeletonIKControllerSnapshot,
56
56
  GLTFImportedSkeletonIKOptions,
57
57
  GLTFImportedSkeletonIKResult,
58
58
  GLTFSceneAnimationApplyResult,
59
+ GLTFSceneAnimationClipSample,
60
+ GLTFSceneAnimationClipBoneMask,
59
61
  GLTFSceneAnimationActionSnapshot,
60
62
  GLTFSceneAnimationCloneSample,
61
63
  GLTFSceneAnimationCloneSampleResult,
@@ -68,7 +70,9 @@ export type {
68
70
  GLTFSceneAnimationRuntimeOptions,
69
71
  GLTFSceneAnimationRuntimeSnapshot,
70
72
  GLTFSceneMorphTargetControllerOptions,
71
- GLTFSceneMorphTargetControllerSnapshot
73
+ GLTFSceneMorphTargetControllerSnapshot,
74
+ GLTFScenePose,
75
+ GLTFScenePoseBoneTransform
72
76
  } from "./GLTFAnimationRuntime";
73
77
  export { AssetHandle } from "./AssetHandle";
74
78
  export type { AssetHandleOptions, AssetHandleStatus } from "./AssetHandle";
@@ -225,7 +229,9 @@ export type {
225
229
  } from "./CarConceptMaterialStability";
226
230
  export { ImageLoader } from "./ImageLoader";
227
231
  export type { ImageAsset } from "./ImageLoader";
228
- export { GLTFLoader } from "./GLTFLoader";
232
+ export { GLTFLoader, normalizeSkinWeights } from "./GLTFLoader";
233
+ export { autoFitGLTFScene, computeAutoFitTransform } from "./GLTFAutoFit";
234
+ export type { AutoFitOptions, AutoFitTransform, GLTFUpAxis } from "./GLTFAutoFit";
229
235
  export type {
230
236
  GLTFAsset,
231
237
  GLTFCameraAsset,
@@ -238,6 +244,7 @@ export type {
238
244
  GLTFLightAsset,
239
245
  GLTFLoaderOptions,
240
246
  GLTFLoaderDiagnostics,
247
+ SkinWeightNormalizationStats,
241
248
  GLTFMaterialAsset,
242
249
  GLTFMaterialVariantAsset,
243
250
  GLTFMaterialVariantMappingAsset,
@@ -267,7 +274,8 @@ export {
267
274
  createDefaultGLTFHdrStudioPreviewEnvironmentLighting,
268
275
  createGLTFRenderResourceDiagnostics,
269
276
  createGLTFRenderResources,
270
- createGLTFRenderSource
277
+ createGLTFRenderSource,
278
+ renderStateForGLTFMaterial
271
279
  } from "./GLTFRenderResources";
272
280
  export type {
273
281
  DecodedGLTFImage,
@@ -1,6 +1,88 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { AssetManager, GLTFLoader, OBJLoader, type AssetLoader } from "../src/index";
3
+ import {
4
+ AssetManager,
5
+ computeAutoFitTransform,
6
+ GLTFLoader,
7
+ normalizeSkinWeights,
8
+ OBJLoader,
9
+ resolveGLTFClipName,
10
+ type AssetLoader
11
+ } from "../src/index";
12
+
13
+ test("normalizeSkinWeights collapses degenerate (zero-sum) weights to a rigid binding", () => {
14
+ const stats = { degenerate: 0, rescaled: 0 };
15
+ assert.deepEqual(normalizeSkinWeights([0, 0, 0, 0], stats), [1, 0, 0, 0]);
16
+ assert.equal(stats.degenerate, 1);
17
+ assert.equal(stats.rescaled, 0);
18
+ });
19
+
20
+ test("normalizeSkinWeights rescales denormalized weights to sum 1 (FLOAT)", () => {
21
+ const stats = { degenerate: 0, rescaled: 0 };
22
+ const result = normalizeSkinWeights([2, 2, 0, 0], stats);
23
+ assert.deepEqual(result, [0.5, 0.5, 0, 0]);
24
+ assert.equal(stats.rescaled, 1);
25
+ });
26
+
27
+ test("normalizeSkinWeights rescales integer-encoded weights regardless of componentType scale", () => {
28
+ // UNSIGNED_BYTE-style raw values (sum 255) and UNSIGNED_SHORT-style (sum 65535)
29
+ // both normalize by proportion to the same result.
30
+ assert.deepEqual(normalizeSkinWeights([255, 0, 0, 0]), [1, 0, 0, 0]);
31
+ assert.deepEqual(normalizeSkinWeights([32767, 32768, 0, 0]).map((value) => Math.round(value * 100) / 100), [0.5, 0.5, 0, 0]);
32
+ });
33
+
34
+ test("normalizeSkinWeights clamps negative and non-finite components to zero", () => {
35
+ assert.deepEqual(normalizeSkinWeights([1, -1, Number.NaN, Number.POSITIVE_INFINITY]), [1, 0, 0, 0]);
36
+ });
37
+
38
+ test("normalizeSkinWeights leaves already-normalized weights untouched", () => {
39
+ const stats = { degenerate: 0, rescaled: 0 };
40
+ assert.deepEqual(normalizeSkinWeights([0.6, 0.4, 0, 0], stats), [0.6, 0.4, 0, 0]);
41
+ assert.equal(stats.degenerate, 0);
42
+ assert.equal(stats.rescaled, 0);
43
+ });
44
+
45
+ test("resolveGLTFClipName matches exact (case/format-insensitive) first", () => {
46
+ assert.equal(resolveGLTFClipName("Idle", ["Walk", "idle", "Run"]), "idle");
47
+ assert.equal(resolveGLTFClipName("walk-cycle", ["Run", "Walk_Cycle"]), "Walk_Cycle");
48
+ });
49
+
50
+ test("resolveGLTFClipName resolves via synonym groups", () => {
51
+ assert.equal(resolveGLTFClipName("idle", ["Loops", "Sprint"]), "Loops");
52
+ assert.equal(resolveGLTFClipName("run", ["Loops", "Sprint"]), "Sprint");
53
+ assert.equal(resolveGLTFClipName("wave", ["Greet", "Static"]), "Greet");
54
+ });
55
+
56
+ test("resolveGLTFClipName falls back to substring then first clip", () => {
57
+ assert.equal(resolveGLTFClipName("walk", ["WalkForward", "Run"]), "WalkForward");
58
+ assert.equal(resolveGLTFClipName("nonexistent", ["Take 001", "Other"]), "Take 001");
59
+ assert.equal(resolveGLTFClipName("anything", []), undefined);
60
+ });
61
+
62
+ test("computeAutoFitTransform scales tall and tiny models to the target height", () => {
63
+ const big = computeAutoFitTransform({ min: [-900, 0, -900], max: [900, 1821, 900] }, { targetHeight: 1.6 });
64
+ assert.ok(Math.abs(big.fittedHeight - 1.6) < 1e-6);
65
+ assert.ok(big.scale < 0.01);
66
+
67
+ const tiny = computeAutoFitTransform({ min: [-0.5, 0, -0.5], max: [0.5, 1, 0.5] });
68
+ assert.ok(Math.abs(tiny.fittedHeight - 1.6) < 1e-6);
69
+ assert.ok(tiny.scale > 1);
70
+ });
71
+
72
+ test("computeAutoFitTransform recenters feet to y=0 and centers x/z", () => {
73
+ const fit = computeAutoFitTransform({ min: [2, 4, 6], max: [4, 6, 8] }, { targetHeight: 2 });
74
+ // height 2 -> scale 1; center (3,5,7), floor y=4
75
+ assert.equal(fit.scale, 1);
76
+ assert.deepEqual(fit.translation, [-3, -4, -7]);
77
+ });
78
+
79
+ test("computeAutoFitTransform applies z-up correction", () => {
80
+ const fit = computeAutoFitTransform({ min: [-1, -1, 0], max: [1, 1, 4] }, { upAxis: "z", targetHeight: 4 });
81
+ assert.equal(fit.upAxisCorrection, "z-to-y");
82
+ // after z->y rotation the source z-extent (4) becomes height -> scale 1
83
+ assert.ok(Math.abs(fit.fittedHeight - 4) < 1e-6);
84
+ assert.notDeepEqual(fit.rotation, [0, 0, 0, 1]);
85
+ });
4
86
 
5
87
  test("AssetManager shares duplicate in-flight loads and releases cached handles", async () => {
6
88
  let loads = 0;