@babylonjs/loaders 9.26.0 → 9.26.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.
Files changed (72) hide show
  1. package/FBX/fbxConstraintBehavior.d.ts +188 -0
  2. package/FBX/fbxConstraintBehavior.js +532 -0
  3. package/FBX/fbxConstraintBehavior.js.map +1 -0
  4. package/FBX/fbxFileLoader.pure.d.ts +175 -6
  5. package/FBX/fbxFileLoader.pure.js +1521 -286
  6. package/FBX/fbxFileLoader.pure.js.map +1 -1
  7. package/FBX/index.d.ts +4 -1
  8. package/FBX/index.js +1 -0
  9. package/FBX/index.js.map +1 -1
  10. package/FBX/interpreter/animation.d.ts +73 -33
  11. package/FBX/interpreter/animation.js +620 -223
  12. package/FBX/interpreter/animation.js.map +1 -1
  13. package/FBX/interpreter/animationCurve.d.ts +91 -0
  14. package/FBX/interpreter/animationCurve.js +623 -0
  15. package/FBX/interpreter/animationCurve.js.map +1 -0
  16. package/FBX/interpreter/blendShapes.js +9 -18
  17. package/FBX/interpreter/blendShapes.js.map +1 -1
  18. package/FBX/interpreter/connections.d.ts +1 -1
  19. package/FBX/interpreter/connections.js +173 -34
  20. package/FBX/interpreter/connections.js.map +1 -1
  21. package/FBX/interpreter/constraints.d.ts +68 -0
  22. package/FBX/interpreter/constraints.js +143 -0
  23. package/FBX/interpreter/constraints.js.map +1 -0
  24. package/FBX/interpreter/fbxInterpreter.d.ts +89 -4
  25. package/FBX/interpreter/fbxInterpreter.js +474 -122
  26. package/FBX/interpreter/fbxInterpreter.js.map +1 -1
  27. package/FBX/interpreter/geometry.d.ts +1 -1
  28. package/FBX/interpreter/geometry.js +150 -92
  29. package/FBX/interpreter/geometry.js.map +1 -1
  30. package/FBX/interpreter/legacyDocument.d.ts +11 -0
  31. package/FBX/interpreter/legacyDocument.js +425 -0
  32. package/FBX/interpreter/legacyDocument.js.map +1 -0
  33. package/FBX/interpreter/materialModel.d.ts +71 -0
  34. package/FBX/interpreter/materialModel.js +622 -0
  35. package/FBX/interpreter/materialModel.js.map +1 -0
  36. package/FBX/interpreter/materials.d.ts +13 -1
  37. package/FBX/interpreter/materials.js +88 -5
  38. package/FBX/interpreter/materials.js.map +1 -1
  39. package/FBX/interpreter/nodeTransform.d.ts +32 -0
  40. package/FBX/interpreter/nodeTransform.js +54 -0
  41. package/FBX/interpreter/nodeTransform.js.map +1 -0
  42. package/FBX/interpreter/nurbs.d.ts +99 -0
  43. package/FBX/interpreter/nurbs.js +627 -0
  44. package/FBX/interpreter/nurbs.js.map +1 -0
  45. package/FBX/interpreter/propertyTemplates.d.ts +23 -0
  46. package/FBX/interpreter/propertyTemplates.js +109 -2
  47. package/FBX/interpreter/propertyTemplates.js.map +1 -1
  48. package/FBX/interpreter/rig.js +16 -0
  49. package/FBX/interpreter/rig.js.map +1 -1
  50. package/FBX/interpreter/sceneDiagnostics.js +28 -3
  51. package/FBX/interpreter/sceneDiagnostics.js.map +1 -1
  52. package/FBX/interpreter/skeleton.d.ts +4 -18
  53. package/FBX/interpreter/skeleton.js +16 -94
  54. package/FBX/interpreter/skeleton.js.map +1 -1
  55. package/FBX/parsers/fbxAsciiParser.js +119 -9
  56. package/FBX/parsers/fbxAsciiParser.js.map +1 -1
  57. package/FBX/parsers/fbxBinaryParser.js +74 -22
  58. package/FBX/parsers/fbxBinaryParser.js.map +1 -1
  59. package/FBX/pure.d.ts +1 -0
  60. package/FBX/pure.js +1 -0
  61. package/FBX/pure.js.map +1 -1
  62. package/FBX/types/fbxTypes.d.ts +18 -2
  63. package/FBX/types/fbxTypes.js +40 -12
  64. package/FBX/types/fbxTypes.js.map +1 -1
  65. package/USD/usdCommandProtocol.d.ts +20 -4
  66. package/USD/usdCommandProtocol.js +25 -3
  67. package/USD/usdCommandProtocol.js.map +1 -1
  68. package/USD/usdFileLoader.pure.js +2 -0
  69. package/USD/usdFileLoader.pure.js.map +1 -1
  70. package/USD/usdSceneMaterializer.js +430 -92
  71. package/USD/usdSceneMaterializer.js.map +1 -1
  72. package/package.json +3 -3
@@ -1,9 +1,11 @@
1
1
  /* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
2
2
  import { RegisterSceneLoaderPlugin, } from "@babylonjs/core/Loading/sceneLoader.js";
3
3
  import { Mesh } from "@babylonjs/core/Meshes/mesh.pure.js";
4
+ import { LinesMesh } from "@babylonjs/core/Meshes/linesMesh.pure.js";
4
5
  import { SubMesh } from "@babylonjs/core/Meshes/subMesh.js";
5
6
  import { VertexData } from "@babylonjs/core/Meshes/mesh.vertexData.js";
6
7
  import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial.pure.js";
8
+ import { PBRMaterial } from "@babylonjs/core/Materials/PBR/pbrMaterial.pure.js";
7
9
  import { Material } from "@babylonjs/core/Materials/material.js";
8
10
  import { MultiMaterial } from "@babylonjs/core/Materials/multiMaterial.pure.js";
9
11
  import { Texture } from "@babylonjs/core/Materials/Textures/texture.pure.js";
@@ -26,8 +28,10 @@ import { GetMimeType } from "@babylonjs/core/Misc/fileTools.pure.js";
26
28
  import { parseBinaryFBX } from "./parsers/fbxBinaryParser.js";
27
29
  import { parseAsciiFBX } from "./parsers/fbxAsciiParser.js";
28
30
  import { interpretFBX } from "./interpreter/fbxInterpreter.js";
29
- import { sampleFBXCurveAtTime } from "./interpreter/animation.js";
30
- import { computeFBXGeometricDeltaMatrix, computeFBXGeometricMatrix, computeFBXGeometricNormalMatrix, computeFBXLocalMatrix } from "./interpreter/transform.js";
31
+ import { FBXConstraintBehavior } from "./fbxConstraintBehavior.js";
32
+ import { isPbrShaderType, mapColor, mapScalar } from "./interpreter/materialModel.js";
33
+ import { evaluateLayeredChannel, evaluateLayeredProperty, isChannelSteppedAt, sampleFBXCurveAtTime, } from "./interpreter/animation.js";
34
+ import { computeFBXGeometricDeltaMatrix, computeFBXGeometricMatrix, computeFBXGeometricNormalMatrix, computeFBXLocalMatrix, eulerToMatrix, eulerToMatrixXYZ, } from "./interpreter/transform.js";
31
35
  import { FBXFileLoaderMetadata } from "./fbxFileLoader.metadata.js";
32
36
  const FBX_ASCII_MAGIC = "; FBX";
33
37
  const FBX_BINARY_MAGIC = "Kaydara FBX Binary";
@@ -53,8 +57,36 @@ export class FBXFileLoader {
53
57
  this._bindRestBones = new WeakSet();
54
58
  this._sourceBonesBySkeleton = new WeakMap();
55
59
  this._scaleCompensationHelpersBySkeleton = new WeakMap();
60
+ /** Frame rate of the file being loaded (GlobalSettings TimeMode); animation is baked at this rate. */
61
+ this._frameRate = 30;
62
+ /** Layers of the animation stack currently being converted; used by the transform samplers. */
63
+ this._activeLayers = [];
64
+ /** Parent model per model id, for inherit-mode aware sampling. */
65
+ this._parentModelById = new Map();
66
+ /** Curve nodes per model id for the stack currently being converted. */
67
+ this._curveNodesByModelId = new Map();
68
+ /** Helper nodes inserted above models whose InheritType is not RSrs. */
69
+ this._inheritScaleHelpers = new Map();
70
+ /** First mesh built per (geometry, geometric transform), for geometry sharing between instances. */
71
+ this._meshByGeometryKey = new Map();
72
+ /** Instance mesh -> source mesh whose geometry it shares. */
73
+ this._instanceSource = new Map();
74
+ /** Property curve nodes that were mapped onto Babylon animations; their "not evaluated" diagnostics are dropped. */
75
+ this._evaluatedCurveNodeIds = new Set();
76
+ this._rigBoneModelIds = new Set();
77
+ const full = options.preset === "full";
56
78
  this._options = {
79
+ preset: full ? "full" : "compatible",
57
80
  normalMapCoordinateSystem: options.normalMapCoordinateSystem ?? "y-up",
81
+ materials: options.materials ?? (full ? "auto" : "standard"),
82
+ unitScale: options.unitScale ?? "preserve",
83
+ shareGeometry: options.shareGeometry ?? full,
84
+ onWarning: options.onWarning ?? (() => { }),
85
+ nurbsSubdivision: options.nurbsSubdivision ?? 0,
86
+ curves: options.curves ?? (full ? "lines" : "skip"),
87
+ constraints: options.constraints ?? (full ? "apply" : "metadata"),
88
+ rebaseAnimations: options.rebaseAnimations ?? !full,
89
+ attachCamerasAndLights: options.attachCamerasAndLights ?? full,
58
90
  };
59
91
  }
60
92
  /**
@@ -76,9 +108,9 @@ export class FBXFileLoader {
76
108
  * @returns A promise containing the loaded meshes, particle systems, skeletons, animation groups, transform nodes, geometries, and lights
77
109
  */
78
110
  async importMeshAsync(meshesNames, scene, data, rootUrl, _onProgress, _fileName) {
79
- const doc = this._parse(data);
80
- const fbxScene = interpretFBX(doc);
81
- return this._buildScene(fbxScene, scene, rootUrl, meshesNames);
111
+ const fbxScene = this._parseAndInterpret(data);
112
+ const result = this._buildScene(fbxScene, scene, rootUrl, meshesNames);
113
+ return result;
82
114
  }
83
115
  /**
84
116
  * Loads all FBX content into the scene.
@@ -90,8 +122,7 @@ export class FBXFileLoader {
90
122
  * @returns A promise that resolves when loading is complete
91
123
  */
92
124
  async loadAsync(scene, data, rootUrl, _onProgress, _fileName) {
93
- const doc = this._parse(data);
94
- const fbxScene = interpretFBX(doc);
125
+ const fbxScene = this._parseAndInterpret(data);
95
126
  this._buildScene(fbxScene, scene, rootUrl, null);
96
127
  }
97
128
  /**
@@ -104,8 +135,7 @@ export class FBXFileLoader {
104
135
  * @returns A promise containing the loaded asset container
105
136
  */
106
137
  async loadAssetContainerAsync(scene, data, rootUrl, _onProgress, _fileName) {
107
- const doc = this._parse(data);
108
- const fbxScene = interpretFBX(doc);
138
+ const fbxScene = this._parseAndInterpret(data);
109
139
  const container = new AssetContainer(scene);
110
140
  // Build the scene into a temporary holder, then move results to container
111
141
  const result = this._buildScene(fbxScene, scene, rootUrl, null);
@@ -134,6 +164,10 @@ export class FBXFileLoader {
134
164
  this._addTextureToContainer(texture, container);
135
165
  }
136
166
  for (const mesh of result.meshes) {
167
+ // Lines meshes own an internal colour shader; it is not an asset of the file.
168
+ if (mesh instanceof LinesMesh) {
169
+ continue;
170
+ }
137
171
  this._addMaterialToContainer(mesh.material, container);
138
172
  }
139
173
  // Remove all added objects from the scene (container owns them)
@@ -142,6 +176,17 @@ export class FBXFileLoader {
142
176
  return container;
143
177
  }
144
178
  // ── Parsing ────────────────────────────────────────────────────────────
179
+ /**
180
+ * Parses and interprets the file. Parsing is synchronous, so no progress events are emitted: the scene loader's
181
+ * progress callback reports download bytes and must not be fed synthetic counts.
182
+ */
183
+ _parseAndInterpret(data) {
184
+ const doc = this._parse(data);
185
+ return interpretFBX(doc, {
186
+ nurbsSubdivision: this._options.nurbsSubdivision > 0 ? this._options.nurbsSubdivision : undefined,
187
+ rebaseKeyframes: this._options.rebaseAnimations,
188
+ });
189
+ }
145
190
  _parse(data) {
146
191
  if (data instanceof ArrayBuffer) {
147
192
  return this._parseFromArrayBuffer(data);
@@ -163,9 +208,11 @@ export class FBXFileLoader {
163
208
  if (header.startsWith(FBX_BINARY_MAGIC)) {
164
209
  return parseBinaryFBX(buffer);
165
210
  }
166
- // Try ASCII
211
+ // Try ASCII. Most files start with "; FBX x.y.z project file", but the header comment is optional:
212
+ // some exporters start straight at FBXHeaderExtension or even at Objects.
167
213
  const text = new TextDecoder("utf-8").decode(buffer);
168
- if (text.trimStart().startsWith(FBX_ASCII_MAGIC)) {
214
+ const head = text.substring(0, 4096);
215
+ if (head.trimStart().startsWith(FBX_ASCII_MAGIC) || /^\s*(?:;[^\n]*\n\s*)*(?:FBXHeaderExtension|Definitions|Objects|Connections)\s*:/.test(head)) {
169
216
  return parseAsciiFBX(text);
170
217
  }
171
218
  throw new Error("FBXFileLoader: unrecognized FBX format");
@@ -173,6 +220,8 @@ export class FBXFileLoader {
173
220
  // ── Scene Building ─────────────────────────────────────────────────────
174
221
  _buildScene(fbxScene, scene, rootUrl, meshesNames) {
175
222
  const nameFilter = this._buildNameFilter(meshesNames);
223
+ this._frameRate = fbxScene.frameRate > 0 ? fbxScene.frameRate : 30;
224
+ this._inheritScaleHelpers = new Map();
176
225
  // Create materials
177
226
  const materialCache = new Map();
178
227
  for (const matData of fbxScene.materials) {
@@ -189,6 +238,12 @@ export class FBXFileLoader {
189
238
  for (const skin of fbxScene.skins) {
190
239
  skinById.set(skin.id, skin);
191
240
  }
241
+ this._rigBoneModelIds = new Set();
242
+ for (const rig of fbxScene.rigs) {
243
+ for (const bone of rig.bones) {
244
+ this._rigBoneModelIds.add(bone.modelId);
245
+ }
246
+ }
192
247
  for (const rig of fbxScene.rigs) {
193
248
  const skeleton = this._createSkeleton(rig.id, rig.bones, scene);
194
249
  skeletons.push(skeleton);
@@ -205,13 +260,17 @@ export class FBXFileLoader {
205
260
  }
206
261
  // Collect model data for animation sampling.
207
262
  const modelIdToData = new Map();
208
- const collectModelData = (models) => {
263
+ this._parentModelById = new Map();
264
+ const collectModelData = (models, parent) => {
209
265
  for (const m of models) {
210
266
  modelIdToData.set(m.id, m);
211
- collectModelData(m.children);
267
+ if (parent) {
268
+ this._parentModelById.set(m.id, parent);
269
+ }
270
+ collectModelData(m.children, m);
212
271
  }
213
272
  };
214
- collectModelData(fbxScene.rootModels);
273
+ collectModelData(fbxScene.rootModels, null);
215
274
  const cullingConflictMaterialIds = FBXFileLoader._collectCullingConflictMaterialIds(fbxScene.rootModels);
216
275
  const cullingMaterialCloneCache = new Map();
217
276
  // Build the FBX hierarchy under the same handedness conversion root that
@@ -223,6 +282,17 @@ export class FBXFileLoader {
223
282
  rootNode.rotation.y = Math.PI;
224
283
  rootNode.scaling.z = -1;
225
284
  }
285
+ // FBX units: one file unit is UnitScaleFactor centimeters.
286
+ const unitScale = this._options.unitScale;
287
+ const cmPerTargetUnit = unitScale === "preserve" ? 0 : unitScale === "meters" ? 100 : unitScale;
288
+ if (cmPerTargetUnit > 0 && fbxScene.unitScaleFactor > 0) {
289
+ const factor = fbxScene.unitScaleFactor / cmPerTargetUnit;
290
+ rootNode.scaling.scaleInPlace(factor);
291
+ }
292
+ rootNode.metadata = { ...(rootNode.metadata ?? {}), fbxUnitScaleFactor: fbxScene.unitScaleFactor, fbxFrameRate: fbxScene.frameRate };
293
+ this._meshByGeometryKey = new Map();
294
+ this._instanceSource = new Map();
295
+ this._evaluatedCurveNodeIds = new Set();
226
296
  const meshes = [];
227
297
  const transformNodes = [rootNode];
228
298
  let assetRoot = rootNode;
@@ -256,59 +326,56 @@ export class FBXFileLoader {
256
326
  boneTransformNodes.add(transformNode);
257
327
  }
258
328
  }
259
- for (const boneData of rig.bones) {
260
- if (!boneData.isCluster) {
261
- continue;
262
- }
263
- const boneNode = modelIdToNode.get(boneData.modelId);
264
- const bone = this._getSourceBone(skeleton, boneData.index);
265
- if (!boneNode || !bone) {
266
- continue;
267
- }
268
- // Find direct children of this bone's TransformNode that aren't bones themselves
269
- for (const child of [...boneNode.getChildren()]) {
270
- const childTransform = child;
271
- if (!boneTransformNodes.has(childTransform)) {
272
- const childWorld = childTransform.computeWorldMatrix(true).clone();
273
- const boneReferenceWorld = FBXFileLoader._getBoneReferenceWorldMatrix(skeleton, bone, boneReferenceNode, skinnedMesh);
274
- const boneReferenceWorldInv = new Matrix();
275
- boneReferenceWorld.invertToRef(boneReferenceWorldInv);
276
- const childLocalToBone = childWorld.multiply(boneReferenceWorldInv);
277
- childTransform.parent = null;
278
- childTransform.attachToBone(bone, boneReferenceNode);
279
- FBXFileLoader._applyMatrixToTransform(childTransform, childLocalToBone);
280
- }
281
- }
282
- }
329
+ // Children of joints (props, end effectors, meshes) stay parented to the joint's TransformNode. Bones are
330
+ // linked to those nodes, so the hierarchy already follows the animation; attaching to the bone's skinning
331
+ // matrix would move them into the skinned mesh's space instead of the node's.
332
+ void boneTransformNodes;
333
+ void boneReferenceNode;
283
334
  }
284
335
  // Apply blend shapes (morph targets) to meshes
285
336
  if (fbxScene.blendShapes.length > 0) {
286
337
  this._applyBlendShapes(fbxScene.blendShapes, meshes, scene);
287
- }
288
- // Create animation groups
289
- const animationGroups = [];
290
- for (const animStack of fbxScene.animations) {
291
- const group = this._createAnimationGroup(animStack, fbxScene.rigs, skeletonByRigId, scene, modelIdToNode, modelIdToData, meshes);
292
- if (group) {
293
- animationGroups.push(group);
338
+ for (const [instance, source] of Array.from(this._instanceSource)) {
339
+ if (source.morphTargetManager && !instance.morphTargetManager) {
340
+ instance.morphTargetManager = source.morphTargetManager;
341
+ }
294
342
  }
295
343
  }
296
344
  // Create cameras
297
345
  const cameras = [];
346
+ const cameraByAttributeId = new Map();
298
347
  for (const camData of fbxScene.cameras) {
299
348
  const cam = this._createCamera(camData, modelIdToNode, scene);
300
349
  if (cam) {
301
350
  cameras.push(cam);
351
+ cameraByAttributeId.set(camData.attributeId, cam);
302
352
  }
303
353
  }
304
354
  // Create lights
305
355
  const sceneLights = [];
356
+ const lightByAttributeId = new Map();
306
357
  for (const lightData of fbxScene.lights) {
307
358
  const light = this._createLight(lightData, modelIdToNode, scene);
308
359
  if (light) {
309
360
  sceneLights.push(light);
361
+ lightByAttributeId.set(lightData.attributeId, light);
362
+ }
363
+ }
364
+ this._applyConstraints(fbxScene, modelIdToNode, rootNode, scene);
365
+ // Create animation groups
366
+ const animationGroups = [];
367
+ for (const animStack of fbxScene.animations) {
368
+ const group = this._createAnimationGroup(animStack, fbxScene.rigs, skeletonByRigId, scene, modelIdToNode, modelIdToData, meshes, {
369
+ cameraByAttributeId,
370
+ lightByAttributeId,
371
+ materialCache,
372
+ });
373
+ if (group) {
374
+ animationGroups.push(group);
310
375
  }
311
376
  }
377
+ // Reported last so that property curves the animation groups picked up are not flagged as unevaluated.
378
+ this._reportDiagnostics(fbxScene, rootNode, modelIdToNode);
312
379
  return {
313
380
  meshes,
314
381
  particleSystems: [],
@@ -398,18 +465,60 @@ export class FBXFileLoader {
398
465
  }
399
466
  return Matrix.FromValues(basisRows[0][0], basisRows[0][1], basisRows[0][2], 0, basisRows[1][0], basisRows[1][1], basisRows[1][2], 0, basisRows[2][0], basisRows[2][1], basisRows[2][2], 0, 0, 0, 0, 1);
400
467
  }
401
- _buildModel(model, scene, parent, assetRoot, parentFBXWorldMatrix, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache) {
468
+ _buildModel(model, scene, parentNode, assetRoot, parentFBXWorldMatrix, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache) {
469
+ let parent = parentNode;
402
470
  const localMatrix = FBXFileLoader._computeFBXModelLocalMatrix(model);
403
471
  const fbxWorldMatrix = localMatrix.multiply(parentFBXWorldMatrix);
404
- if (model.geometry && model.subType === "Mesh" && (!nameFilter || nameFilter(model.name))) {
405
- // Create mesh
406
- const skeleton = skeletonByGeometryId.get(model.geometry.id);
407
- const skin = skinByGeometryId.get(model.geometry.id);
408
- const skinBinding = skinBindingByGeometryId.get(model.geometry.id);
472
+ // Nodes with RrSs / Rrs inheritance do not inherit their parent's scale the normal way. Insert a helper that
473
+ // cancels the parent scale and re-express the local transform in that unscaled frame (see ufbx's
474
+ // inherit-mode evaluation). Skeleton bones with Rrs are handled by the rig code instead.
475
+ const parentModel = this._parentModelById.get(model.id);
476
+ const parentModelNode = parentModel ? modelIdToNode.get(parentModel.id) : undefined;
477
+ if (parent &&
478
+ parentModel &&
479
+ parentModelNode &&
480
+ parent === parentModelNode &&
481
+ model.inheritType !== 1 &&
482
+ !this._isRigBone(model.id) &&
483
+ !this._isRigBone(parentModel.id) &&
484
+ !(parentModel.geometry && skinByGeometryId.has(parentModel.geometry.id))) {
485
+ // The helper sits beside the parent and carries the parent's rotation and translation with unit scale,
486
+ // i.e. the parent's "unscaled" frame. This avoids inverting the parent scale (which may be zero).
487
+ const helper = new TransformNode(`${model.name}__fbx_inheritScale`, scene);
488
+ helper.parent = parentModelNode.parent;
489
+ helper.position.copyFrom(parentModelNode.position);
490
+ helper.rotationQuaternion = parentModelNode.rotationQuaternion ? parentModelNode.rotationQuaternion.clone() : Quaternion.FromEulerVector(parentModelNode.rotation);
491
+ helper.scaling.set(1, 1, 1);
492
+ transformNodes.push(helper);
493
+ this._inheritScaleHelpers.set(model.id, helper);
494
+ parent = helper;
495
+ }
496
+ if (model.geometry && model.geometry.indices.length > 0 && (!nameFilter || nameFilter(model.name))) {
497
+ // Create mesh. Tessellated NURBS carry no control point mapping, so their deformers are not applied
498
+ // (the interpreter reports them).
499
+ const deformable = model.geometry.controlPointIndices !== null;
500
+ const skeleton = deformable ? skeletonByGeometryId.get(model.geometry.id) : undefined;
501
+ const skin = deformable ? skinByGeometryId.get(model.geometry.id) : undefined;
502
+ const skinBinding = deformable ? skinBindingByGeometryId.get(model.geometry.id) : undefined;
409
503
  if (skeleton && skin) {
410
504
  skeleton.needInitialSkinMatrix = true;
411
505
  }
412
- const mesh = this._createMesh(model, model.geometry, scene, skeleton, skin, skinBinding);
506
+ // Models referencing the same geometry (with the same geometric transform) share one Babylon geometry.
507
+ const geometryKey = `${model.geometry.id}|${model.geometricTranslation.join(",")}|${model.geometricRotation.join(",")}|${model.geometricScaling.join(",")}`;
508
+ const source = !skeleton && this._options.shareGeometry ? this._meshByGeometryKey.get(geometryKey) : undefined;
509
+ let mesh;
510
+ if (source) {
511
+ mesh = source.clone(model.name, null, true);
512
+ mesh.sideOrientation = source.sideOrientation;
513
+ mesh.metadata = { ...(source.metadata ?? {}), fbxInstanceOf: source.name };
514
+ this._instanceSource.set(mesh, source);
515
+ }
516
+ else {
517
+ mesh = this._createMesh(model, model.geometry, scene, skeleton, skin, skinBinding);
518
+ if (!skeleton) {
519
+ this._meshByGeometryKey.set(geometryKey, mesh);
520
+ }
521
+ }
413
522
  // For skinned meshes: keep bind/pose math in FBX space, but parent
414
523
  // the rendered mesh under the same conversion root as non-skinned
415
524
  // meshes. The pose matrix cancels the real FBX mesh transform only;
@@ -426,7 +535,7 @@ export class FBXFileLoader {
426
535
  if (parent) {
427
536
  mesh.parent = parent;
428
537
  }
429
- FBXFileLoader._applyFBXTransform(mesh, model);
538
+ this._applyRestTRS(mesh, model);
430
539
  }
431
540
  // Apply material(s)
432
541
  if (model.materials.length > 1 && model.geometry?.materialIndices) {
@@ -451,8 +560,21 @@ export class FBXFileLoader {
451
560
  this._buildModel(child, scene, mesh, assetRoot, fbxWorldMatrix, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache);
452
561
  }
453
562
  }
563
+ else if (model.curve && this._options.curves !== "skip" && model.curve.polylines.length > 0 && (!nameFilter || nameFilter(model.name))) {
564
+ const lines = this._createLinesMesh(model, model.curve, scene);
565
+ if (parent) {
566
+ lines.parent = parent;
567
+ }
568
+ this._applyRestTRS(lines, model);
569
+ meshes.push(lines);
570
+ modelIdToNode.set(model.id, lines);
571
+ FBXFileLoader._applyModelMetadata(lines, model);
572
+ for (const child of model.children) {
573
+ this._buildModel(child, scene, lines, assetRoot, fbxWorldMatrix, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache);
574
+ }
575
+ }
454
576
  else {
455
- if (model.geometry && model.subType === "Mesh" && nameFilter && !FBXFileLoader._modelSubtreeMatchesNameFilter(model, nameFilter)) {
577
+ if ((model.geometry || model.curve) && nameFilter && !FBXFileLoader._modelSubtreeMatchesNameFilter(model, nameFilter)) {
456
578
  return;
457
579
  }
458
580
  // Transform node (Null type or no geometry)
@@ -461,7 +583,7 @@ export class FBXFileLoader {
461
583
  transformNode.parent = parent;
462
584
  }
463
585
  // Apply full FBX transform chain
464
- FBXFileLoader._applyFBXTransform(transformNode, model);
586
+ this._applyRestTRS(transformNode, model);
465
587
  transformNodes.push(transformNode);
466
588
  modelIdToNode.set(model.id, transformNode);
467
589
  FBXFileLoader._applyModelMetadata(transformNode, model);
@@ -469,6 +591,7 @@ export class FBXFileLoader {
469
591
  for (const child of model.children) {
470
592
  this._buildModel(child, scene, transformNode, assetRoot, fbxWorldMatrix, materialCache, nameFilter, meshes, transformNodes, skeletonByGeometryId, skinByGeometryId, skinBindingByGeometryId, modelIdToNode, cullingConflictMaterialIds, cullingMaterialCloneCache);
471
593
  }
594
+ FBXFileLoader._applyLodGroup(model, modelIdToNode);
472
595
  }
473
596
  }
474
597
  _linkSkeletonsToTransformNodes(rigs, skeletonByRigId, modelIdToNode, transformNodes, scene) {
@@ -512,14 +635,117 @@ export class FBXFileLoader {
512
635
  return false;
513
636
  }
514
637
  static _applyModelMetadata(node, model) {
515
- if (!model.customProperties && model.diagnostics.length === 0) {
638
+ if (!model.customProperties && model.diagnostics.length === 0 && !model.displayLayer && !model.lodGroup) {
516
639
  return;
517
640
  }
518
641
  node.metadata = {
519
642
  ...(node.metadata ?? {}),
520
- ...(model.customProperties ? { fbxCustomProperties: model.customProperties } : {}),
643
+ ...(model.customProperties ? { fbxCustomProperties: model.customProperties, fbxUserProperties: model.customProperties } : {}),
521
644
  ...(model.diagnostics.length > 0 ? { fbxDiagnostics: model.diagnostics } : {}),
645
+ ...(model.displayLayer
646
+ ? { fbxDisplayLayer: { name: model.displayLayer.name, show: model.displayLayer.show, freeze: model.displayLayer.freeze, color: model.displayLayer.color } }
647
+ : {}),
648
+ ...(model.lodGroup ? { fbxLodGroup: model.lodGroup } : {}),
649
+ };
650
+ // Hidden display layers hide their members, as in the authoring application.
651
+ if (model.displayLayer && !model.displayLayer.show) {
652
+ node.setEnabled(false);
653
+ }
654
+ }
655
+ /**
656
+ * Wires a LodGroup's children as Babylon LOD levels: the first child holds the highest detail; every further
657
+ * child replaces it beyond the group's threshold distance (or screen coverage when thresholds are percentages).
658
+ * Each child's display mode is honoured first: level 1 (show) stays visible outside the LOD chain, level 2
659
+ * (hide) is disabled, and only level 0 (use LOD) children take part in the distance switching.
660
+ */
661
+ static _applyLodGroup(model, modelIdToNode) {
662
+ const lod = model.lodGroup;
663
+ if (!lod) {
664
+ return;
665
+ }
666
+ const lodChildren = [];
667
+ for (let level = 0; level < model.children.length; level++) {
668
+ const child = model.children[level];
669
+ const display = lod.displayLevels[level] ?? 0;
670
+ if (display === 2) {
671
+ modelIdToNode.get(child.id)?.setEnabled(false);
672
+ }
673
+ else if (display === 0) {
674
+ lodChildren.push({ child, level });
675
+ }
676
+ }
677
+ if (lodChildren.length < 2) {
678
+ return;
679
+ }
680
+ const meshesOf = (child) => {
681
+ const node = modelIdToNode.get(child.id);
682
+ if (!node) {
683
+ return [];
684
+ }
685
+ const meshes = node instanceof Mesh ? [node] : [];
686
+ for (const m of node.getChildMeshes(false)) {
687
+ if (m instanceof Mesh) {
688
+ meshes.push(m);
689
+ }
690
+ }
691
+ return meshes;
522
692
  };
693
+ const base = meshesOf(lodChildren[0].child);
694
+ for (let i = 1; i < lodChildren.length; i++) {
695
+ const level = lodChildren[i].level;
696
+ const levelMeshes = meshesOf(lodChildren[i].child);
697
+ const threshold = lod.thresholds[level - 1] ?? lod.thresholds[lod.thresholds.length - 1] ?? 0;
698
+ for (let i = 0; i < base.length; i++) {
699
+ const target = levelMeshes[i] ?? null;
700
+ if (lod.relative) {
701
+ base[i].useLODScreenCoverage = true;
702
+ base[i].addLODLevel(threshold / 100, target);
703
+ }
704
+ else {
705
+ base[i].addLODLevel(threshold, target);
706
+ }
707
+ if (target) {
708
+ target.metadata = { ...(target.metadata ?? {}), fbxLodLevel: level };
709
+ }
710
+ }
711
+ }
712
+ }
713
+ /** Builds a lines mesh from Line or tessellated NurbsCurve geometry, applying the model's geometric transform. */
714
+ _createLinesMesh(model, curve, scene) {
715
+ const lines = new LinesMesh(model.name, scene);
716
+ const positions = [];
717
+ const indices = [];
718
+ for (const polyline of curve.polylines) {
719
+ const count = polyline.length / 3;
720
+ if (count < 2) {
721
+ continue;
722
+ }
723
+ const base = positions.length / 3;
724
+ for (let i = 0; i < polyline.length; i++) {
725
+ positions.push(polyline[i]);
726
+ }
727
+ for (let i = 0; i < count - 1; i++) {
728
+ indices.push(base + i, base + i + 1);
729
+ }
730
+ }
731
+ const geometricMatrix = FBXFileLoader._computeFBXGeometricMatrix(model.geometricTranslation, model.geometricRotation, model.geometricScaling);
732
+ if (!geometricMatrix.equals(Matrix.Identity())) {
733
+ for (let i = 0; i < positions.length; i += 3) {
734
+ const v = Vector3.TransformCoordinates(new Vector3(positions[i], positions[i + 1], positions[i + 2]), geometricMatrix);
735
+ positions[i] = v.x;
736
+ positions[i + 1] = v.y;
737
+ positions[i + 2] = v.z;
738
+ }
739
+ }
740
+ const vertexData = new VertexData();
741
+ vertexData.positions = positions;
742
+ vertexData.indices = indices;
743
+ vertexData.applyToMesh(lines);
744
+ if (curve.color) {
745
+ lines.color = new Color3(curve.color[0], curve.color[1], curve.color[2]);
746
+ }
747
+ lines.metadata = { ...(lines.metadata ?? {}), fbxCurveType: curve.kind, fbxCurveSegments: curve.polylines.length };
748
+ return lines;
523
749
  }
524
750
  _createMesh(model, geomData, scene, skeleton, skin, skinBinding) {
525
751
  const mesh = new Mesh(model.name, scene);
@@ -651,6 +877,18 @@ export class FBXFileLoader {
651
877
  */
652
878
  _applyMultiMaterial(mesh, model, materialCache, scene, cullingConflictMaterialIds, cullingMaterialCloneCache) {
653
879
  const matIndices = model.geometry.materialIndices;
880
+ const recorded = mesh.metadata?.fbxSubMeshMaterialIndices;
881
+ if (recorded) {
882
+ // Instance of an already split mesh: the index buffer and sub-meshes are shared; only the materials differ.
883
+ const multiMat = new MultiMaterial(model.name + "_multi", scene);
884
+ for (const matIdx of recorded) {
885
+ const fbxMat = model.materials[matIdx];
886
+ const mat = fbxMat ? materialCache.get(fbxMat.id) : undefined;
887
+ multiMat.subMaterials.push(mat ? FBXFileLoader._getModelMaterial(mat, model, cullingMaterialCloneCache, cullingConflictMaterialIds.has(fbxMat.id)) : null);
888
+ }
889
+ mesh.material = multiMat;
890
+ return;
891
+ }
654
892
  const indices = mesh.getIndices();
655
893
  if (!indices) {
656
894
  return;
@@ -707,6 +945,7 @@ export class FBXFileLoader {
707
945
  const range = subMeshRanges[i];
708
946
  new SubMesh(i, 0, vertexCount, range.start, range.count, mesh);
709
947
  }
948
+ mesh.metadata = { ...(mesh.metadata ?? {}), fbxSubMeshMaterialIndices: sortedMatIndices };
710
949
  }
711
950
  static _collectCullingConflictMaterialIds(models) {
712
951
  // Deliberately scan the full scene, not just name-filtered models. This
@@ -750,7 +989,7 @@ export class FBXFileLoader {
750
989
  if (cached) {
751
990
  return cached;
752
991
  }
753
- const clone = material.clone(`${material.name}_CullingOff`);
992
+ const clone = material.clone(`${material.name}_CullingOff`) ?? material;
754
993
  clone.backFaceCulling = false;
755
994
  cullingCloneCache?.set(material, clone);
756
995
  return clone;
@@ -761,26 +1000,40 @@ export class FBXFileLoader {
761
1000
  }
762
1001
  if (material instanceof MultiMaterial) {
763
1002
  for (const subMaterial of material.subMaterials) {
764
- if (subMaterial instanceof StandardMaterial) {
1003
+ if (subMaterial instanceof StandardMaterial || subMaterial instanceof PBRMaterial) {
765
1004
  this._applyStandardMaterialUVSetCoordinates(subMaterial, geometry);
766
1005
  }
767
1006
  }
768
1007
  return;
769
1008
  }
770
- if (material instanceof StandardMaterial) {
1009
+ if (material instanceof StandardMaterial || material instanceof PBRMaterial) {
771
1010
  this._applyStandardMaterialUVSetCoordinates(material, geometry);
772
1011
  }
773
1012
  }
774
1013
  _applyStandardMaterialUVSetCoordinates(material, geometry) {
775
- for (const texture of [
776
- material.diffuseTexture,
777
- material.bumpTexture,
778
- material.emissiveTexture,
779
- material.ambientTexture,
780
- material.specularTexture,
781
- material.opacityTexture,
782
- material.reflectionTexture,
783
- ]) {
1014
+ for (const texture of material instanceof PBRMaterial
1015
+ ? [
1016
+ material.albedoTexture,
1017
+ material.bumpTexture,
1018
+ material.emissiveTexture,
1019
+ material.ambientTexture,
1020
+ material.metallicTexture,
1021
+ material.reflectivityTexture,
1022
+ material.microSurfaceTexture,
1023
+ material.opacityTexture,
1024
+ material.clearCoat.texture,
1025
+ material.clearCoat.bumpTexture,
1026
+ material.sheen.texture,
1027
+ ]
1028
+ : [
1029
+ material.diffuseTexture,
1030
+ material.bumpTexture,
1031
+ material.emissiveTexture,
1032
+ material.ambientTexture,
1033
+ material.specularTexture,
1034
+ material.opacityTexture,
1035
+ material.reflectionTexture,
1036
+ ]) {
784
1037
  if (!texture) {
785
1038
  continue;
786
1039
  }
@@ -804,26 +1057,26 @@ export class FBXFileLoader {
804
1057
  if (!assignedMat) {
805
1058
  return;
806
1059
  }
807
- if (assignedMat instanceof StandardMaterial) {
808
- if (!assignedMat.diffuseTexture) {
809
- const clone = assignedMat.clone(`${assignedMat.name}_VertexColor`);
1060
+ const unmodulated = (mat) => {
1061
+ if (mat instanceof StandardMaterial && !mat.diffuseTexture) {
1062
+ const clone = mat.clone(`${mat.name}_VertexColor`);
810
1063
  clone.diffuseColor = new Color3(1, 1, 1);
811
- mesh.material = clone;
1064
+ return clone;
812
1065
  }
813
- return;
814
- }
1066
+ if (mat instanceof PBRMaterial && !mat.albedoTexture) {
1067
+ const clone = mat.clone(`${mat.name}_VertexColor`);
1068
+ clone.albedoColor = new Color3(1, 1, 1);
1069
+ return clone;
1070
+ }
1071
+ return mat;
1072
+ };
815
1073
  if (assignedMat instanceof MultiMaterial) {
816
1074
  const multiMat = new MultiMaterial(`${assignedMat.name}_VertexColor`, scene);
817
- multiMat.subMaterials = assignedMat.subMaterials.map((sub) => {
818
- if (sub instanceof StandardMaterial && !sub.diffuseTexture) {
819
- const clone = sub.clone(`${sub.name}_VertexColor`);
820
- clone.diffuseColor = new Color3(1, 1, 1);
821
- return clone;
822
- }
823
- return sub;
824
- });
1075
+ multiMat.subMaterials = assignedMat.subMaterials.map((sub) => (sub ? unmodulated(sub) : sub));
825
1076
  mesh.material = multiMat;
1077
+ return;
826
1078
  }
1079
+ mesh.material = unmodulated(assignedMat);
827
1080
  }
828
1081
  /**
829
1082
  * Build per-polygon-vertex bone indices and weights from the control-point-based skin data.
@@ -900,6 +1153,278 @@ export class FBXFileLoader {
900
1153
  };
901
1154
  }
902
1155
  _createMaterial(matData, scene, rootUrl) {
1156
+ const mode = this._options.materials;
1157
+ const usePbr = mode === "pbr" || (mode === "auto" && isPbrShaderType(matData.model.shaderType));
1158
+ const material = usePbr ? this._createPbrMaterial(matData, scene, rootUrl) : this._createStandardMaterial(matData, scene, rootUrl);
1159
+ material.metadata = {
1160
+ ...(material.metadata ?? {}),
1161
+ ...(matData.userProperties ? { fbxUserProperties: matData.userProperties } : {}),
1162
+ fbxShaderType: matData.model.shaderType,
1163
+ fbxFeatures: Object.fromEntries(Object.entries(matData.model.features).map(([k, v]) => [k, v?.enabled ?? false])),
1164
+ };
1165
+ return material;
1166
+ }
1167
+ _createPbrMaterial(matData, scene, rootUrl) {
1168
+ const material = new PBRMaterial(matData.name, scene);
1169
+ const pbr = matData.model.pbr;
1170
+ const features = matData.model.features;
1171
+ const enabled = (name) => features[name]?.enabled ?? false;
1172
+ const specGloss = matData.model.shaderType === "3dsMaxPbrSpecGloss";
1173
+ const scaleColor = (c, f) => c ? new Color3(c[0] * (f ?? 1), c[1] * (f ?? 1), c[2] * (f ?? 1)) : undefined;
1174
+ // Base colour
1175
+ const baseColor = mapColor(pbr.baseColor);
1176
+ material.albedoColor = scaleColor(baseColor, pbr.baseColor?.texture ? 1 : mapScalar(pbr.baseFactor)) ?? Color3.White();
1177
+ // Metal / rough or spec / gloss workflow
1178
+ if (specGloss) {
1179
+ material.metallic = null;
1180
+ material.roughness = null;
1181
+ material.reflectivityColor = scaleColor(mapColor(pbr.specularColor), 1) ?? Color3.White();
1182
+ material.microSurface = mapScalar(pbr.glossiness) ?? 1 - (mapScalar(pbr.roughness) ?? 0.5);
1183
+ }
1184
+ else {
1185
+ material.metallic = enabled("metalness") ? (mapScalar(pbr.metalness) ?? 0) : 0;
1186
+ material.roughness = mapScalar(pbr.roughness) ?? (pbr.glossiness?.value ? 1 - pbr.glossiness.value[0] : 0.5);
1187
+ if (enabled("specular") && pbr.specularFactor?.value) {
1188
+ material.metallicF0Factor = mapScalar(pbr.specularFactor) ?? 1;
1189
+ }
1190
+ if (enabled("specular")) {
1191
+ const specularColor = mapColor(pbr.specularColor);
1192
+ if (specularColor) {
1193
+ material.metallicReflectanceColor = new Color3(specularColor[0], specularColor[1], specularColor[2]);
1194
+ }
1195
+ }
1196
+ }
1197
+ // Emission
1198
+ const emission = scaleColor(mapColor(pbr.emissionColor), pbr.emissionColor?.texture ? 1 : mapScalar(pbr.emissionFactor));
1199
+ if (emission && enabled("emission")) {
1200
+ material.emissiveColor = emission;
1201
+ }
1202
+ else if (emission && (emission.r > 0 || emission.g > 0 || emission.b > 0)) {
1203
+ material.emissiveColor = emission;
1204
+ }
1205
+ // Opacity / transparency
1206
+ const opacity = mapColor(pbr.opacity);
1207
+ if (opacity && enabled("opacity")) {
1208
+ material.alpha = (opacity[0] + opacity[1] + opacity[2]) / 3;
1209
+ }
1210
+ else if (!isPbrShaderType(matData.model.shaderType)) {
1211
+ material.alpha = FBXFileLoader._alphaFromClassicTransparency(matData);
1212
+ }
1213
+ if (material.alpha < 1) {
1214
+ material.transparencyMode = Material.MATERIAL_ALPHABLEND;
1215
+ }
1216
+ // Transmission (refraction)
1217
+ const transmission = mapScalar(pbr.transmissionFactor) ?? 0;
1218
+ if (enabled("transmission") && isPbrShaderType(matData.model.shaderType) && transmission > 0) {
1219
+ material.subSurface.isRefractionEnabled = true;
1220
+ material.subSurface.refractionIntensity = transmission;
1221
+ const tint = mapColor(pbr.transmissionColor);
1222
+ if (tint) {
1223
+ material.subSurface.tintColor = new Color3(tint[0], tint[1], tint[2]);
1224
+ }
1225
+ const ior = mapScalar(pbr.specularIor);
1226
+ if (ior && enabled("ior")) {
1227
+ material.subSurface.indexOfRefraction = ior;
1228
+ }
1229
+ }
1230
+ else {
1231
+ const ior = mapScalar(pbr.specularIor);
1232
+ if (ior && enabled("ior") && ior > 1) {
1233
+ material.indexOfRefraction = ior;
1234
+ }
1235
+ }
1236
+ // Clear coat
1237
+ const coat = mapScalar(pbr.coatFactor) ?? 0;
1238
+ if ((enabled("coat") && coat > 0) || pbr.coatFactor?.texture) {
1239
+ material.clearCoat.isEnabled = true;
1240
+ material.clearCoat.intensity = coat;
1241
+ const coatRough = mapScalar(pbr.coatRoughness);
1242
+ if (coatRough !== undefined) {
1243
+ material.clearCoat.roughness = coatRough;
1244
+ }
1245
+ const coatIor = mapScalar(pbr.coatIor);
1246
+ if (coatIor !== undefined && coatIor > 1) {
1247
+ material.clearCoat.indexOfRefraction = coatIor;
1248
+ }
1249
+ const coatColor = mapColor(pbr.coatColor);
1250
+ if (coatColor && (coatColor[0] !== 1 || coatColor[1] !== 1 || coatColor[2] !== 1)) {
1251
+ material.clearCoat.isTintEnabled = true;
1252
+ material.clearCoat.tintColor = new Color3(coatColor[0], coatColor[1], coatColor[2]);
1253
+ }
1254
+ }
1255
+ // Sheen
1256
+ const sheen = mapScalar(pbr.sheenFactor) ?? 0;
1257
+ if ((enabled("sheen") && sheen > 0) || pbr.sheenColor?.texture) {
1258
+ material.sheen.isEnabled = true;
1259
+ material.sheen.intensity = sheen;
1260
+ const sheenColor = mapColor(pbr.sheenColor);
1261
+ if (sheenColor) {
1262
+ material.sheen.color = new Color3(sheenColor[0], sheenColor[1], sheenColor[2]);
1263
+ }
1264
+ const sheenRough = mapScalar(pbr.sheenRoughness);
1265
+ if (sheenRough !== undefined) {
1266
+ material.sheen.roughness = sheenRough;
1267
+ }
1268
+ }
1269
+ if (enabled("unlit")) {
1270
+ material.unlit = true;
1271
+ }
1272
+ if (enabled("doubleSided")) {
1273
+ material.backFaceCulling = false;
1274
+ }
1275
+ // Textures
1276
+ const textureFor = (map, isNormal = false) => {
1277
+ if (!map?.texture || !map.textureEnabled) {
1278
+ return null;
1279
+ }
1280
+ const texture = FBXFileLoader._createTexture(map.texture, scene, rootUrl, isNormal);
1281
+ if (texture) {
1282
+ FBXFileLoader._applyTextureSettings(texture, map.texture);
1283
+ }
1284
+ return texture;
1285
+ };
1286
+ const albedo = textureFor(pbr.baseColor);
1287
+ if (albedo) {
1288
+ material.albedoTexture = albedo;
1289
+ }
1290
+ const normal = textureFor(pbr.normalMap, true);
1291
+ if (normal) {
1292
+ material.bumpTexture = normal;
1293
+ this._configureNormalTexture(normal, material);
1294
+ }
1295
+ const emissiveTex = textureFor(pbr.emissionColor);
1296
+ if (emissiveTex) {
1297
+ material.emissiveTexture = emissiveTex;
1298
+ if (!emission || (emission.r === 0 && emission.g === 0 && emission.b === 0)) {
1299
+ material.emissiveColor = Color3.White();
1300
+ }
1301
+ }
1302
+ const ao = textureFor(pbr.ambientOcclusion);
1303
+ if (ao) {
1304
+ material.ambientTexture = ao;
1305
+ material.useAmbientInGrayScale = true;
1306
+ }
1307
+ if (specGloss) {
1308
+ const spec = textureFor(pbr.specularColor);
1309
+ if (spec) {
1310
+ material.reflectivityTexture = spec;
1311
+ }
1312
+ const gloss = textureFor(pbr.glossiness) ?? textureFor(pbr.roughness);
1313
+ if (gloss) {
1314
+ material.microSurfaceTexture = gloss;
1315
+ }
1316
+ }
1317
+ else {
1318
+ // Babylon reads metalness and roughness from one texture (B = metal, G = rough). Grayscale maps have
1319
+ // identical channels, so either map works alone; two different maps cannot both be honoured. The choice
1320
+ // is made on the references so that only the texture actually used is created.
1321
+ const metalRef = pbr.metalness?.textureEnabled ? pbr.metalness.texture : undefined;
1322
+ const roughSource = pbr.roughness?.textureEnabled && pbr.roughness.texture ? pbr.roughness : pbr.glossiness?.textureEnabled && pbr.glossiness.texture ? pbr.glossiness : undefined;
1323
+ const roughRef = roughSource?.texture;
1324
+ const sameTexture = !!metalRef && !!roughRef && metalRef.id === roughRef.id;
1325
+ let usesMetalTexture = false;
1326
+ if (metalRef && (sameTexture || !roughRef)) {
1327
+ const metalTex = textureFor(pbr.metalness);
1328
+ if (metalTex) {
1329
+ usesMetalTexture = true;
1330
+ material.metallicTexture = metalTex;
1331
+ material.useMetallnessFromMetallicTextureBlue = true;
1332
+ material.useRoughnessFromMetallicTextureGreen = sameTexture;
1333
+ material.useRoughnessFromMetallicTextureAlpha = false;
1334
+ }
1335
+ }
1336
+ else if (roughRef) {
1337
+ const roughTex = textureFor(roughSource);
1338
+ if (roughTex) {
1339
+ material.metallicTexture = roughTex;
1340
+ material.useMetallnessFromMetallicTextureBlue = false;
1341
+ material.useRoughnessFromMetallicTextureGreen = true;
1342
+ material.useRoughnessFromMetallicTextureAlpha = false;
1343
+ if (metalRef) {
1344
+ material.metadata = { ...(material.metadata ?? {}), fbxDroppedMetalnessTexture: metalRef.relativeFileName };
1345
+ }
1346
+ }
1347
+ }
1348
+ // A roughness-only texture keeps the scalar metalness; a metalness texture needs metallic = 1 to show.
1349
+ if (usesMetalTexture && material.metallic === 0) {
1350
+ material.metallic = 1;
1351
+ }
1352
+ }
1353
+ if (pbr.opacity?.texture && pbr.opacity.textureEnabled) {
1354
+ if (albedo && pbr.opacity.texture.id === pbr.baseColor?.texture?.id) {
1355
+ material.useAlphaFromAlbedoTexture = true;
1356
+ material.transparencyMode = Material.MATERIAL_ALPHATESTANDBLEND;
1357
+ }
1358
+ else {
1359
+ const opacityTex = textureFor(pbr.opacity);
1360
+ if (opacityTex) {
1361
+ material.opacityTexture = opacityTex;
1362
+ material.transparencyMode = Material.MATERIAL_ALPHATESTANDBLEND;
1363
+ }
1364
+ }
1365
+ }
1366
+ if (material.clearCoat.isEnabled) {
1367
+ const coatTex = textureFor(pbr.coatFactor);
1368
+ if (coatTex) {
1369
+ material.clearCoat.texture = coatTex;
1370
+ }
1371
+ const coatNormal = textureFor(pbr.coatNormal, true);
1372
+ if (coatNormal) {
1373
+ material.clearCoat.bumpTexture = coatNormal;
1374
+ }
1375
+ }
1376
+ if (material.sheen.isEnabled) {
1377
+ const sheenTex = textureFor(pbr.sheenColor);
1378
+ if (sheenTex) {
1379
+ material.sheen.texture = sheenTex;
1380
+ }
1381
+ }
1382
+ return material;
1383
+ }
1384
+ /** Alpha of a classic Lambert/Phong material: Opacity when present, otherwise 1 - TransparentColor * TransparencyFactor. */
1385
+ static _alphaFromClassicTransparency(matData) {
1386
+ const props = matData.properties;
1387
+ if (props.opacity !== undefined) {
1388
+ return props.opacity;
1389
+ }
1390
+ const factor = mapScalar(matData.model.fbx.transparencyFactor);
1391
+ if (factor === undefined) {
1392
+ return 1;
1393
+ }
1394
+ const color = mapColor(matData.model.fbx.transparencyColor);
1395
+ const amount = color ? (color[0] + color[1] + color[2]) / 3 : 1;
1396
+ return Math.min(1, Math.max(0, 1 - amount * factor));
1397
+ }
1398
+ static _applyTextureSettings(texture, tex) {
1399
+ if (tex.uvTranslation) {
1400
+ texture.uOffset = tex.uvTranslation[0];
1401
+ texture.vOffset = tex.uvTranslation[1];
1402
+ }
1403
+ if (tex.uvScaling) {
1404
+ texture.uScale = tex.uvScaling[0];
1405
+ texture.vScale = tex.uvScaling[1];
1406
+ }
1407
+ if (tex.uvRotation !== undefined) {
1408
+ texture.wAng = tex.uvRotation * (Math.PI / 180);
1409
+ }
1410
+ if (tex.uvSetIndex !== undefined) {
1411
+ texture.coordinatesIndex = tex.uvSetIndex;
1412
+ }
1413
+ if (tex.wrapU !== undefined) {
1414
+ texture.wrapU = tex.wrapU === 1 ? Texture.CLAMP_ADDRESSMODE : Texture.WRAP_ADDRESSMODE;
1415
+ }
1416
+ if (tex.wrapV !== undefined) {
1417
+ texture.wrapV = tex.wrapV === 1 ? Texture.CLAMP_ADDRESSMODE : Texture.WRAP_ADDRESSMODE;
1418
+ }
1419
+ if (tex.uvSetName || tex.layeredTextureId !== undefined) {
1420
+ texture.metadata = {
1421
+ ...(texture.metadata ?? {}),
1422
+ ...(tex.uvSetName ? { fbxUVSetName: tex.uvSetName } : {}),
1423
+ ...(tex.layeredTextureId !== undefined ? { fbxLayeredTextureId: tex.layeredTextureId } : {}),
1424
+ };
1425
+ }
1426
+ }
1427
+ _createStandardMaterial(matData, scene, rootUrl) {
903
1428
  const material = new StandardMaterial(matData.name, scene);
904
1429
  const props = matData.properties;
905
1430
  const hasTexture = (...slots) => matData.textures.some((texture) => slots.includes(texture.propertyName));
@@ -922,12 +1447,7 @@ export class FBXFileLoader {
922
1447
  const emissiveFactor = hasTexture("EmissiveColor", "Emissive") ? 1 : (props.emissiveFactor ?? 1);
923
1448
  material.emissiveColor = new Color3(props.emissiveColor[0] * emissiveFactor, props.emissiveColor[1] * emissiveFactor, props.emissiveColor[2] * emissiveFactor);
924
1449
  }
925
- if (props.opacity !== undefined) {
926
- material.alpha = props.opacity;
927
- }
928
- else if (props.transparencyFactor !== undefined) {
929
- material.alpha = 1 - props.transparencyFactor;
930
- }
1450
+ material.alpha = FBXFileLoader._alphaFromClassicTransparency(matData);
931
1451
  if (material.alpha < 1) {
932
1452
  material.transparencyMode = Material.MATERIAL_ALPHABLEND;
933
1453
  }
@@ -991,27 +1511,7 @@ export class FBXFileLoader {
991
1511
  // Shininess map — no direct StandardMaterial slot
992
1512
  break;
993
1513
  }
994
- // Apply UV transforms
995
- if (tex.uvTranslation) {
996
- texture.uOffset = tex.uvTranslation[0];
997
- texture.vOffset = tex.uvTranslation[1];
998
- }
999
- if (tex.uvScaling) {
1000
- texture.uScale = tex.uvScaling[0];
1001
- texture.vScale = tex.uvScaling[1];
1002
- }
1003
- if (tex.uvRotation !== undefined) {
1004
- texture.wAng = tex.uvRotation * (Math.PI / 180);
1005
- }
1006
- if (tex.uvSetIndex !== undefined) {
1007
- texture.coordinatesIndex = tex.uvSetIndex;
1008
- }
1009
- if (tex.uvSetName) {
1010
- texture.metadata = {
1011
- ...(texture.metadata ?? {}),
1012
- fbxUVSetName: tex.uvSetName,
1013
- };
1014
- }
1514
+ FBXFileLoader._applyTextureSettings(texture, tex);
1015
1515
  }
1016
1516
  return material;
1017
1517
  }
@@ -1273,6 +1773,8 @@ export class FBXFileLoader {
1273
1773
  const position = Vector3.TransformCoordinates(Vector3.Zero(), worldMatrix);
1274
1774
  const camera = new FreeCamera(camData.name, position, scene);
1275
1775
  camera.fov = camData.fieldOfView * (Math.PI / 180);
1776
+ camera.fovMode = Camera.FOVMODE_VERTICAL_FIXED;
1777
+ void position;
1276
1778
  camera.minZ = camData.nearPlane;
1277
1779
  camera.maxZ = camData.farPlane;
1278
1780
  camera.metadata = {
@@ -1285,25 +1787,44 @@ export class FBXFileLoader {
1285
1787
  orthoZoom: camData.orthoZoom,
1286
1788
  roll: camData.roll,
1287
1789
  aspectRatio: camData.aspectRatio,
1790
+ fieldOfViewX: camData.fieldOfViewX,
1791
+ apertureMode: camData.apertureMode,
1792
+ apertureSizeInch: camData.apertureSizeInch,
1793
+ userProperties: camData.userProperties,
1288
1794
  unknownProperties: camData.unknownProperties,
1289
1795
  diagnostics: camData.diagnostics,
1290
1796
  },
1291
1797
  };
1292
1798
  if (camData.projectionType === "orthographic") {
1293
- const orthoHeight = camData.orthoZoom && camData.orthoZoom > 0 ? camData.orthoZoom : 1;
1294
- const aspect = camData.aspectRatio > 0 ? camData.aspectRatio : 1;
1295
1799
  camera.mode = Camera.ORTHOGRAPHIC_CAMERA;
1296
- camera.orthoTop = orthoHeight / 2;
1297
- camera.orthoBottom = -orthoHeight / 2;
1298
- camera.orthoRight = (orthoHeight * aspect) / 2;
1299
- camera.orthoLeft = -(orthoHeight * aspect) / 2;
1300
- }
1301
- // FBX cameras look down their local +X axis. Derive the world-space look-at target from the
1302
- // node's world matrix using point transforms so the file's handedness conversion (the
1303
- // left-handed root applies scaling.z = -1) is reproduced correctly. Transforming a direction
1304
- // with the rotation alone would mirror it under that reflection and aim the camera wrongly.
1305
- const target = Vector3.TransformCoordinates(new Vector3(1, 0, 0), worldMatrix);
1306
- camera.setTarget(target);
1800
+ camera.orthoTop = camData.orthographicSize[1] / 2;
1801
+ camera.orthoBottom = -camData.orthographicSize[1] / 2;
1802
+ camera.orthoRight = camData.orthographicSize[0] / 2;
1803
+ camera.orthoLeft = -camData.orthographicSize[0] / 2;
1804
+ }
1805
+ // FBX cameras look down their local +X axis. When attached, the camera is parented to its node so it follows
1806
+ // the node's animation, and oriented with a fixed local rotation (camera forward +Z -> node +X), with the FBX
1807
+ // Roll applied around the view axis first. Otherwise it is placed in world space, unparented.
1808
+ if (parentNode && this._options.attachCamerasAndLights) {
1809
+ // The handedness conversion at the root mirrors every node's world matrix. A camera cannot render through
1810
+ // a mirrored parent (its view matrix would flip the image and the winding), so when the node's world
1811
+ // determinant is negative the camera hangs from a pivot whose own mirror cancels it.
1812
+ let cameraParent = parentNode;
1813
+ if (worldMatrix.determinant() < 0) {
1814
+ const pivot = new TransformNode(`${camData.name}__fbx_cameraPivot`, scene);
1815
+ pivot.parent = parentNode;
1816
+ pivot.scaling.set(1, 1, -1);
1817
+ cameraParent = pivot;
1818
+ }
1819
+ camera.parent = cameraParent;
1820
+ camera.position.set(0, 0, 0);
1821
+ const roll = ((camData.roll ?? 0) * Math.PI) / 180;
1822
+ camera.rotationQuaternion = Quaternion.FromRotationMatrix(Matrix.RotationZ(roll).multiply(Matrix.RotationY(Math.PI / 2)));
1823
+ }
1824
+ else {
1825
+ const target = Vector3.TransformCoordinates(new Vector3(1, 0, 0), worldMatrix);
1826
+ camera.setTarget(target);
1827
+ }
1307
1828
  return camera;
1308
1829
  }
1309
1830
  _createLight(lightData, modelIdToNode, scene) {
@@ -1318,26 +1839,44 @@ export class FBXFileLoader {
1318
1839
  const forwardPoint = Vector3.TransformCoordinates(new Vector3(0, 0, -1), worldMatrix);
1319
1840
  const direction = forwardPoint.subtract(position).normalize();
1320
1841
  let light;
1842
+ // When attached, the light is parented to its node (so it follows animation) and its position and direction
1843
+ // are expressed in node-local space: origin and local -Z. Otherwise it is placed in world space, unparented.
1844
+ const attach = !!parentNode && this._options.attachCamerasAndLights;
1845
+ const localPosition = attach ? Vector3.Zero() : position;
1846
+ const localDirection = attach ? new Vector3(0, 0, -1) : direction;
1321
1847
  switch (lightData.lightType) {
1322
1848
  case 1: // Directional
1323
- light = new DirectionalLight(lightData.name, direction, scene);
1849
+ light = new DirectionalLight(lightData.name, localDirection, scene);
1324
1850
  light.diffuse = color;
1325
1851
  light.intensity = lightData.intensity;
1326
1852
  break;
1327
1853
  case 2: {
1328
1854
  // Spot
1329
1855
  const angle = lightData.coneAngle * (Math.PI / 180);
1330
- light = new SpotLight(lightData.name, position, direction, angle, 2, scene);
1856
+ light = new SpotLight(lightData.name, localPosition, localDirection, angle, 2, scene);
1331
1857
  light.diffuse = color;
1332
1858
  light.intensity = lightData.intensity;
1859
+ if (lightData.innerAngle !== undefined) {
1860
+ light.innerAngle = Math.min(angle, (lightData.innerAngle * Math.PI) / 180);
1861
+ }
1333
1862
  break;
1334
1863
  }
1335
- default: // Point (0)
1336
- light = new PointLight(lightData.name, position, scene);
1864
+ default: // Point (0), Area (3) and Volume (4) approximated as point lights
1865
+ light = new PointLight(lightData.name, localPosition, scene);
1337
1866
  light.diffuse = color;
1338
1867
  light.intensity = lightData.intensity;
1339
1868
  break;
1340
1869
  }
1870
+ if (attach && parentNode) {
1871
+ light.parent = parentNode;
1872
+ // Babylon only resolves a parented light's world position and direction during rendering; do it now
1873
+ // so getAbsolutePosition() and transformedDirection are usable straight after loading.
1874
+ light.computeTransformedInformation();
1875
+ }
1876
+ light.specular = color;
1877
+ if (lightData.enableFarAttenuation && lightData.farAttenuationEnd !== undefined && lightData.farAttenuationEnd > 0) {
1878
+ light.range = lightData.farAttenuationEnd;
1879
+ }
1341
1880
  light.metadata = {
1342
1881
  ...(light.metadata ?? {}),
1343
1882
  fbxLight: {
@@ -1349,6 +1888,10 @@ export class FBXFileLoader {
1349
1888
  enableNearAttenuation: lightData.enableNearAttenuation,
1350
1889
  enableFarAttenuation: lightData.enableFarAttenuation,
1351
1890
  castShadows: lightData.castShadows,
1891
+ farAttenuationStart: lightData.farAttenuationStart,
1892
+ farAttenuationEnd: lightData.farAttenuationEnd,
1893
+ areaLightShape: lightData.areaLightShape,
1894
+ userProperties: lightData.userProperties,
1352
1895
  unknownProperties: lightData.unknownProperties,
1353
1896
  diagnostics: lightData.diagnostics,
1354
1897
  },
@@ -1363,11 +1906,12 @@ export class FBXFileLoader {
1363
1906
  const authoredAbsoluteMatrices = [];
1364
1907
  const authoredRuntimeLocalMatrices = [];
1365
1908
  // Compute authored Lcl matrices for bones that do not carry FBX bind data.
1909
+ const authoredInheritScales = FBXFileLoader._computeBoneInheritScales(bones, bones.map((b) => b.scale));
1366
1910
  for (let i = 0; i < bones.length; i++) {
1367
1911
  const boneData = bones[i];
1368
1912
  const authoredLocal = FBXFileLoader._computeFBXLocalMatrix(boneData.translation, boneData.rotation, boneData.scale, boneData.preRotation, boneData.postRotation, boneData.rotationPivot, boneData.scalingPivot, boneData.rotationOffset, boneData.scalingOffset, boneData.rotationOrder);
1369
1913
  authoredLocalMatrices[i] = authoredLocal;
1370
- authoredRuntimeLocalMatrices[i] = FBXFileLoader._computeFBXRuntimeLocalMatrix(bones, authoredLocal, i);
1914
+ authoredRuntimeLocalMatrices[i] = FBXFileLoader._computeFBXRuntimeLocalMatrix(bones, authoredLocal, i, authoredInheritScales);
1371
1915
  }
1372
1916
  authoredAbsoluteMatrices.push(...FBXFileLoader._computeFBXAbsoluteMatrices(bones, authoredRuntimeLocalMatrices));
1373
1917
  const absoluteBindMatrices = bones.map((boneData, index) => boneData.transformLinkMatrix
@@ -1391,11 +1935,12 @@ export class FBXFileLoader {
1391
1935
  // Only bones with that scale disagreement need their animation curves
1392
1936
  // remapped into bind-rest space; ordinary child curves are already in
1393
1937
  // the expected local animation space.
1938
+ const restInheritScales = FBXFileLoader._computeBoneInheritScales(bones, bones.map((b) => b.scale));
1394
1939
  for (let i = 0; i < bones.length; i++) {
1395
1940
  let localMatrix = useBindAsRest ? localBindMatrices[i] : authoredRuntimeLocalMatrices[i];
1396
1941
  let parentBone = bones[i].parentIndex >= 0 ? sourceBones[bones[i].parentIndex] : null;
1397
- if (!useBindAsRest && bones[i].inheritType === 2 && bones[i].parentIndex >= 0 && parentBone) {
1398
- const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(authoredLocalMatrices[i], bones[bones[i].parentIndex].scale);
1942
+ if (!useBindAsRest && bones[i].inheritType !== 1 && bones[i].parentIndex >= 0 && parentBone) {
1943
+ const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(authoredLocalMatrices[i], restInheritScales[bones[i].parentIndex], FBXFileLoader._getBoneInheritedScale(bones, i, restInheritScales));
1399
1944
  const helper = new Bone(`${bones[i].name}__fbx_scaleCompensation`, skeleton, parentBone, split.helperLocalMatrix, split.helperLocalMatrix.clone(), Matrix.Identity(), -1);
1400
1945
  helper.metadata = {
1401
1946
  ...(helper.metadata ?? {}),
@@ -1430,6 +1975,9 @@ export class FBXFileLoader {
1430
1975
  }
1431
1976
  return skeleton;
1432
1977
  }
1978
+ _isRigBone(modelId) {
1979
+ return this._rigBoneModelIds.has(modelId);
1980
+ }
1433
1981
  _getSourceBone(skeleton, sourceIndex) {
1434
1982
  return this._sourceBonesBySkeleton.get(skeleton)?.[sourceIndex] ?? skeleton.bones[sourceIndex];
1435
1983
  }
@@ -1448,22 +1996,69 @@ export class FBXFileLoader {
1448
1996
  }
1449
1997
  return absoluteMatrices;
1450
1998
  }
1451
- static _computeFBXRuntimeLocalMatrix(bones, localMatrix, index, parentScaleOverride) {
1999
+ /**
2000
+ * Effective ("inherit") scale of every bone, following the FBX SDK: the local scale for RSrs bones, and for
2001
+ * RrSs / Rrs bones the local scale multiplied by the scale of the bone's inherit-scale node (the parent for RrSs,
2002
+ * the parent's inherit-scale node for Rrs). Bones are ordered parents first.
2003
+ */
2004
+ static _computeBoneInheritScales(bones, ownScales) {
2005
+ const result = [];
2006
+ for (let i = 0; i < bones.length; i++) {
2007
+ const own = ownScales[i];
2008
+ const inheritedFrom = FBXFileLoader._getBoneInheritScaleNode(bones, i);
2009
+ if (inheritedFrom < 0 || inheritedFrom >= i) {
2010
+ result[i] = [own[0], own[1], own[2]];
2011
+ }
2012
+ else {
2013
+ const p = result[inheritedFrom];
2014
+ result[i] = [own[0] * p[0], own[1] * p[1], own[2] * p[2]];
2015
+ }
2016
+ }
2017
+ return result;
2018
+ }
2019
+ static _getBoneInheritScaleNode(bones, index) {
1452
2020
  const parentIndex = bones[index].parentIndex;
1453
- if (bones[index].inheritType !== 2 || parentIndex < 0) {
2021
+ if (parentIndex < 0) {
2022
+ return -1;
2023
+ }
2024
+ if (bones[index].inheritType === 0) {
2025
+ return parentIndex;
2026
+ }
2027
+ if (bones[index].inheritType === 2) {
2028
+ return FBXFileLoader._getBoneInheritScaleNode(bones, parentIndex);
2029
+ }
2030
+ return -1;
2031
+ }
2032
+ /** Scale a bone inherits into its own scale (RrSs chains), or unit scale. */
2033
+ static _getBoneInheritedScale(bones, index, inheritScales) {
2034
+ const node = FBXFileLoader._getBoneInheritScaleNode(bones, index);
2035
+ return node >= 0 ? inheritScales[node] : [1, 1, 1];
2036
+ }
2037
+ static _computeFBXRuntimeLocalMatrix(bones, localMatrix, index, inheritScales) {
2038
+ const parentIndex = bones[index].parentIndex;
2039
+ if (bones[index].inheritType === 1 || parentIndex < 0) {
1454
2040
  return localMatrix;
1455
2041
  }
1456
- const parentScale = parentScaleOverride ?? bones[parentIndex].scale;
1457
- return FBXFileLoader._applyParentScaleCompensation(localMatrix, parentScale);
2042
+ const scales = inheritScales ??
2043
+ FBXFileLoader._computeBoneInheritScales(bones, bones.map((b) => b.scale));
2044
+ return FBXFileLoader._applyParentScaleCompensation(localMatrix, scales[parentIndex], FBXFileLoader._getBoneInheritedScale(bones, index, scales));
1458
2045
  }
1459
- static _applyParentScaleCompensation(localMatrix, parentScale) {
1460
- const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(localMatrix, parentScale);
2046
+ static _applyParentScaleCompensation(localMatrix, parentScale, inheritedScale) {
2047
+ const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(localMatrix, parentScale, inheritedScale);
1461
2048
  return split.boneLocalMatrix.multiply(split.helperLocalMatrix);
1462
2049
  }
1463
- static _splitParentScaleCompensatedLocalMatrix(localMatrix, parentScale) {
2050
+ /**
2051
+ * Splits a bone's FBX local matrix into a helper (which cancels the parent scale and carries the translation,
2052
+ * so the translation still follows the parent scale as the SDK does) and the bone's own rotation/scale. For RrSs
2053
+ * bones the inherited scale is folded into the bone scale.
2054
+ */
2055
+ static _splitParentScaleCompensatedLocalMatrix(localMatrix, parentScale, inheritedScale) {
1464
2056
  const translation = localMatrix.getTranslation();
1465
- const boneLocalMatrix = localMatrix.clone();
2057
+ let boneLocalMatrix = localMatrix.clone();
1466
2058
  boneLocalMatrix.setTranslation(Vector3.Zero());
2059
+ if (inheritedScale && (inheritedScale[0] !== 1 || inheritedScale[1] !== 1 || inheritedScale[2] !== 1)) {
2060
+ boneLocalMatrix = Matrix.Scaling(inheritedScale[0], inheritedScale[1], inheritedScale[2]).multiply(boneLocalMatrix);
2061
+ }
1467
2062
  const helperLocalMatrix = Matrix.Compose(FBXFileLoader._getInverseScaleVector(parentScale), Quaternion.Identity(), translation);
1468
2063
  return { boneLocalMatrix, helperLocalMatrix };
1469
2064
  }
@@ -1529,34 +2124,16 @@ export class FBXFileLoader {
1529
2124
  rotationOrder,
1530
2125
  });
1531
2126
  }
1532
- /**
1533
- * Apply the FBX transform chain to a Babylon TransformNode or Mesh.
1534
- * Decomposes the full local matrix into position/rotation/scale.
1535
- */
1536
- static _applyFBXTransform(node, model) {
1537
- const localMatrix = FBXFileLoader._computeFBXModelLocalMatrix(model);
1538
- // Decompose into TRS
1539
- const s = new Vector3();
1540
- const r = new Quaternion();
1541
- const t = new Vector3();
1542
- localMatrix.decompose(s, r, t);
1543
- node.position = t;
1544
- node.rotationQuaternion = r;
1545
- node.scaling = s;
2127
+ _applyRestTRS(node, model) {
2128
+ const trs = FBXFileLoader._computeLocalTRS(model, model.translation, model.rotation, model.scale);
2129
+ this._adjustTRSForInheritMode(model, trs, undefined);
2130
+ node.position = trs.position;
2131
+ node.rotationQuaternion = trs.rotationQuaternion;
2132
+ node.scaling = trs.scaling;
1546
2133
  }
1547
2134
  static _computeFBXModelLocalMatrix(model) {
1548
2135
  return FBXFileLoader._computeFBXLocalMatrix(model.translation, model.rotation, model.scale, model.preRotation, model.postRotation, model.rotationPivot, model.scalingPivot, model.rotationOffset, model.scalingOffset, model.rotationOrder);
1549
2136
  }
1550
- static _getBoneReferenceWorldMatrix(skeleton, bone, referenceNode, skinnedMesh) {
1551
- if (skinnedMesh) {
1552
- skeleton.getTransformMatrices(skinnedMesh);
1553
- }
1554
- else {
1555
- skeleton.prepare(true);
1556
- }
1557
- referenceNode.computeWorldMatrix(true);
1558
- return bone.getFinalMatrix().multiply(referenceNode.getWorldMatrix());
1559
- }
1560
2137
  static _applyMatrixToTransform(node, matrix) {
1561
2138
  const s = new Vector3();
1562
2139
  const r = new Quaternion();
@@ -1566,10 +2143,25 @@ export class FBXFileLoader {
1566
2143
  node.rotationQuaternion = r;
1567
2144
  node.scaling = s;
1568
2145
  }
1569
- _createAnimationGroup(animStack, rigs, skeletonByRigId, scene, modelIdToNode, modelIdToData, meshes) {
1570
- if (animStack.curveNodes.length === 0) {
2146
+ _createAnimationGroup(animStack, rigs, skeletonByRigId, scene, modelIdToNode, modelIdToData, meshes, propertyTargets) {
2147
+ // Stacks may animate only properties (visibility, light colour...) without any transform curve.
2148
+ if (animStack.curveNodes.length === 0 && !animStack.unsupportedCurveNodes.some((c) => c.targetId !== null && c.curves.length > 0)) {
1571
2149
  return null;
1572
2150
  }
2151
+ this._activeLayers = animStack.layers;
2152
+ this._curveNodesByModelId = new Map();
2153
+ for (const curveNode of animStack.curveNodes) {
2154
+ if (curveNode.type === "DeformPercent") {
2155
+ continue;
2156
+ }
2157
+ const list = this._curveNodesByModelId.get(curveNode.targetModelId);
2158
+ if (list) {
2159
+ list.push(curveNode);
2160
+ }
2161
+ else {
2162
+ this._curveNodesByModelId.set(curveNode.targetModelId, [curveNode]);
2163
+ }
2164
+ }
1573
2165
  const animGroup = new AnimationGroup(animStack.name, scene);
1574
2166
  const animatedBoneTargetProperties = new Map();
1575
2167
  const addBoneAnimation = (animation, bone) => {
@@ -1635,7 +2227,7 @@ export class FBXFileLoader {
1635
2227
  // authored Lcl curves remapped onto the bind-rest local space.
1636
2228
  const inheritedRigModelIds = new Set();
1637
2229
  for (const rig of rigs) {
1638
- const inheritType2ModelIds = new Set(rig.bones.filter((bone) => bone.inheritType === 2).map((bone) => bone.modelId));
2230
+ const inheritType2ModelIds = new Set(rig.bones.filter((bone) => bone.inheritType !== 1 && bone.parentIndex >= 0).map((bone) => bone.modelId));
1639
2231
  if (inheritType2ModelIds.size === 0) {
1640
2232
  continue;
1641
2233
  }
@@ -1686,9 +2278,79 @@ export class FBXFileLoader {
1686
2278
  animGroup.addTargetedAnimation(animation, node);
1687
2279
  }
1688
2280
  }
1689
- // Process blend shape (morph target) animations
2281
+ // Inherit-scale helpers follow the inverse of their parent's (possibly animated) scale. Children of animated
2282
+ // parents also need re-sampling even when they carry no curves of their own, since their local transform
2283
+ // depends on the parent scale.
2284
+ for (const [modelId, helper] of Array.from(this._inheritScaleHelpers)) {
2285
+ const modelData = modelIdToData.get(modelId);
2286
+ const parentModel = modelData ? this._parentModelById.get(modelId) : undefined;
2287
+ if (!modelData || !parentModel) {
2288
+ continue;
2289
+ }
2290
+ const fps = this._frameRate;
2291
+ // The helper mirrors the parent's animated rotation/translation (its own curves plus any inherited scale
2292
+ // that feeds its translation), with unit scale.
2293
+ const parentCurves = [
2294
+ ...(this._curveNodesByModelId.get(parentModel.id) ?? []),
2295
+ ...this._collectInheritScaleCurves(parentModel).filter((cn) => cn.targetModelId !== parentModel.id),
2296
+ ];
2297
+ if (parentCurves.length > 0) {
2298
+ const baseTimes = collectAnimationSampleTimes(parentCurves, fps, animStack.startTime, animStack.stopTime);
2299
+ const sampleParent = (time) => this._computeInheritAwareLocalMatrix(parentModel, this._sampleModelLocalMatrix(parentModel, this._curveNodesByModelId.get(parentModel.id) ?? [], time), time);
2300
+ const times = FBXFileLoader._refineSampleTimes(baseTimes, sampleParent);
2301
+ const posKeys = [];
2302
+ const rotKeys = [];
2303
+ let prevQuat = null;
2304
+ for (const time of times) {
2305
+ const sc = new Vector3();
2306
+ const rq = new Quaternion();
2307
+ const tr = new Vector3();
2308
+ sampleParent(time).decompose(sc, rq, tr);
2309
+ if (prevQuat && Quaternion.Dot(prevQuat, rq) < 0) {
2310
+ rq.scaleInPlace(-1);
2311
+ }
2312
+ prevQuat = rq;
2313
+ posKeys.push({ frame: time * fps, value: tr });
2314
+ rotKeys.push({ frame: time * fps, value: rq });
2315
+ }
2316
+ if (!this._isVector3KeysConstant(posKeys)) {
2317
+ const anim = new Animation(`${helper.name}_position`, "position", fps, Animation.ANIMATIONTYPE_VECTOR3, Animation.ANIMATIONLOOPMODE_CYCLE);
2318
+ anim.setKeys(posKeys);
2319
+ animGroup.addTargetedAnimation(anim, helper);
2320
+ }
2321
+ if (!areQuaternionKeysConstant(rotKeys)) {
2322
+ const anim = new Animation(`${helper.name}_rotation`, "rotationQuaternion", fps, Animation.ANIMATIONTYPE_QUATERNION, Animation.ANIMATIONLOOPMODE_CYCLE);
2323
+ anim.setKeys(rotKeys);
2324
+ animGroup.addTargetedAnimation(anim, helper);
2325
+ }
2326
+ }
2327
+ // The child's own local transform depends on the parent scale; re-sample it when that scale animates.
2328
+ const scaleCurves = this._collectInheritScaleCurves(parentModel);
2329
+ const node = modelIdToNode.get(modelId);
2330
+ if (node && scaleCurves.length > 0 && !nonBoneCurves.has(modelId) && !boneCurves.has(modelId)) {
2331
+ for (const animation of this._buildNodeAnimations([], node.name, modelData, animStack.startTime, animStack.stopTime, scaleCurves)) {
2332
+ animGroup.addTargetedAnimation(animation, node);
2333
+ }
2334
+ }
2335
+ }
2336
+ // Process blend shape (morph target) animations. Every layer animating a channel contributes to one
2337
+ // influence animation evaluated through the layer stack.
2338
+ const blendShapeGroups = new Map();
1690
2339
  for (const curveNode of blendShapeCurves) {
1691
- const targetChannelId = curveNode.targetModelId;
2340
+ const group = blendShapeGroups.get(curveNode.targetModelId);
2341
+ if (group) {
2342
+ group.push(curveNode);
2343
+ }
2344
+ else {
2345
+ blendShapeGroups.set(curveNode.targetModelId, [curveNode]);
2346
+ }
2347
+ }
2348
+ for (const [targetChannelId, group] of Array.from(blendShapeGroups)) {
2349
+ group.sort((a, b) => a.layerIndex - b.layerIndex);
2350
+ const sources = group.filter((cn) => cn.curves.length > 0).map((cn) => ({ layerIndex: cn.layerIndex, curves: cn.curves }));
2351
+ if (sources.length === 0) {
2352
+ continue;
2353
+ }
1692
2354
  // Find the morph target with matching channel ID across all meshes
1693
2355
  let targetFound = false;
1694
2356
  for (const mesh of meshes) {
@@ -1698,15 +2360,15 @@ export class FBXFileLoader {
1698
2360
  const metadata = mesh.metadata;
1699
2361
  const channelTargets = metadata?.fbxBlendShapeChannelTargets;
1700
2362
  const targetInfo = channelTargets?.get(targetChannelId);
1701
- if (targetInfo && curveNode.curves.length > 0) {
1702
- const fps = 30;
2363
+ if (targetInfo) {
2364
+ const fps = this._frameRate;
1703
2365
  for (let shapeIndex = 0; shapeIndex < targetInfo.targetIndices.length; shapeIndex++) {
1704
2366
  const target = mesh.morphTargetManager.getTarget(targetInfo.targetIndices[shapeIndex]);
1705
2367
  if (!target) {
1706
2368
  continue;
1707
2369
  }
1708
2370
  const anim = new Animation(`${target.name}_influence`, "influence", fps, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CYCLE);
1709
- const keys = buildScalarAnimationKeys(curveNode.curves[0], fps, animStack.startTime, animStack.stopTime, (value) => calculateBlendShapeInfluences(value, targetInfo.fullWeights, targetInfo.targetIndices.length)[shapeIndex] ?? 0);
2371
+ const keys = this._layeredScalarKeys(sources, animStack, (value) => calculateBlendShapeInfluences(value, targetInfo.fullWeights, targetInfo.targetIndices.length)[shapeIndex] ?? 0);
1710
2372
  anim.setKeys(keys);
1711
2373
  animGroup.addTargetedAnimation(anim, target);
1712
2374
  }
@@ -1722,26 +2384,53 @@ export class FBXFileLoader {
1722
2384
  continue;
1723
2385
  }
1724
2386
  const target = mesh.morphTargetManager.getTarget(targetIndex);
1725
- if (target && curveNode.curves.length > 0) {
1726
- const fps = 30;
2387
+ if (target) {
2388
+ const fps = this._frameRate;
1727
2389
  const anim = new Animation(`${target.name}_influence`, "influence", fps, Animation.ANIMATIONTYPE_FLOAT, Animation.ANIMATIONLOOPMODE_CYCLE);
1728
- const keys = buildScalarAnimationKeys(curveNode.curves[0], fps, animStack.startTime, animStack.stopTime, (value) => value / 100);
1729
- anim.setKeys(keys);
2390
+ anim.setKeys(this._layeredScalarKeys(sources, animStack, (value) => value / 100));
1730
2391
  animGroup.addTargetedAnimation(anim, target);
1731
2392
  targetFound = true;
1732
2393
  }
1733
2394
  }
1734
2395
  }
2396
+ // Animated non-transform properties: visibility, camera, light and material parameters. Curve nodes of
2397
+ // several layers that animate the same property are evaluated together through the layer stack.
2398
+ const propertyGroups = new Map();
2399
+ for (const curveNode of animStack.unsupportedCurveNodes) {
2400
+ if (curveNode.targetId === null || curveNode.curves.length === 0) {
2401
+ continue;
2402
+ }
2403
+ const key = `${curveNode.targetId}|${curveNode.propertyName ?? curveNode.type}`;
2404
+ const group = propertyGroups.get(key);
2405
+ if (group) {
2406
+ group.push(curveNode);
2407
+ }
2408
+ else {
2409
+ propertyGroups.set(key, [curveNode]);
2410
+ }
2411
+ }
2412
+ for (const group of Array.from(propertyGroups.values())) {
2413
+ group.sort((a, b) => a.layerIndex - b.layerIndex);
2414
+ const built = this._buildPropertyAnimations(group, animStack, modelIdToNode, propertyTargets);
2415
+ if (built.length > 0) {
2416
+ for (const curveNode of group) {
2417
+ this._evaluatedCurveNodeIds.add(curveNode.id);
2418
+ }
2419
+ }
2420
+ for (const { animation, target } of built) {
2421
+ animGroup.addTargetedAnimation(animation, target);
2422
+ }
2423
+ }
1735
2424
  // Normalize the animation group
1736
2425
  if (animGroup.targetedAnimations.length > 0) {
1737
- animGroup.normalize(animStack.startTime * 30, animStack.stopTime * 30);
2426
+ animGroup.normalize(animStack.startTime * this._frameRate, animStack.stopTime * this._frameRate);
1738
2427
  return animGroup;
1739
2428
  }
1740
2429
  animGroup.dispose();
1741
2430
  return null;
1742
2431
  }
1743
2432
  _buildInheritedRigBoneAnimations(rig, skeleton, boneCurves, modelIdToData, compensatedModelIds, startTime, stopTime) {
1744
- const fps = 30;
2433
+ const fps = this._frameRate;
1745
2434
  const sampledModelIds = new Set();
1746
2435
  for (let i = 0; i < rig.bones.length; i++) {
1747
2436
  if (!compensatedModelIds.has(rig.bones[i].modelId)) {
@@ -1752,10 +2441,18 @@ export class FBXFileLoader {
1752
2441
  }
1753
2442
  }
1754
2443
  const rigCurveNodes = rig.bones.filter((bone) => sampledModelIds.has(bone.modelId)).flatMap((bone) => boneCurves.get(bone.modelId) ?? []);
1755
- const times = collectAnimationSampleTimes(rigCurveNodes, fps, startTime, stopTime);
2444
+ let times = collectAnimationSampleTimes(rigCurveNodes, fps, startTime, stopTime);
1756
2445
  if (times.length === 0) {
1757
2446
  return [];
1758
2447
  }
2448
+ for (const boneData of rig.bones) {
2449
+ const md = modelIdToData.get(boneData.modelId);
2450
+ const cn = boneCurves.get(boneData.modelId) ?? [];
2451
+ if (!sampledModelIds.has(boneData.modelId) || !md || cn.length === 0) {
2452
+ continue;
2453
+ }
2454
+ times = FBXFileLoader._refineSampleTimes(times, (time) => this._sampleModelLocalMatrix(md, cn, time));
2455
+ }
1759
2456
  const keysByBone = rig.bones.map(() => ({
1760
2457
  posKeys: [],
1761
2458
  rotKeys: [],
@@ -1796,14 +2493,15 @@ export class FBXFileLoader {
1796
2493
  const curveNodes = boneCurves.get(boneData.modelId) ?? [];
1797
2494
  return modelData ? this._sampleModelScale(modelData, curveNodes, time) : boneData.scale;
1798
2495
  });
2496
+ const sampledInheritScales = FBXFileLoader._computeBoneInheritScales(rig.bones, sampledScales);
1799
2497
  const frame = time * fps;
1800
2498
  for (let i = 0; i < localMatrices.length; i++) {
1801
2499
  if (!compensatedModelIds.has(rig.bones[i].modelId)) {
1802
2500
  continue;
1803
2501
  }
1804
2502
  const parentIndex = rig.bones[i].parentIndex;
1805
- const parentScale = parentIndex >= 0 ? sampledScales[parentIndex] : rig.bones[i].scale;
1806
- const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(localMatrices[i], parentScale);
2503
+ const parentScale = parentIndex >= 0 ? sampledInheritScales[parentIndex] : rig.bones[i].scale;
2504
+ const split = FBXFileLoader._splitParentScaleCompensatedLocalMatrix(localMatrices[i], parentScale, FBXFileLoader._getBoneInheritedScale(rig.bones, i, sampledInheritScales));
1807
2505
  FBXFileLoader._pushMatrixKeys(keysByBone[i], frame, split.boneLocalMatrix);
1808
2506
  FBXFileLoader._pushMatrixKeys(keysByHelper[i], frame, split.helperLocalMatrix);
1809
2507
  }
@@ -1881,58 +2579,46 @@ export class FBXFileLoader {
1881
2579
  * Build animations for a non-bone node, correctly handling pivots.
1882
2580
  * Computes the full FBX transform matrix at each keyframe and decomposes into TRS.
1883
2581
  */
1884
- _buildNodeAnimations(curveNodes, nodeName, modelData, startTime, stopTime) {
1885
- const fps = 30;
2582
+ _buildNodeAnimations(curveNodes, nodeName, modelData, startTime, stopTime, extraTimeCurves = []) {
2583
+ const fps = this._frameRate;
1886
2584
  // Separate curves by type
1887
- const tNode = curveNodes.find((cn) => cn.type === "T");
1888
2585
  const rNode = curveNodes.find((cn) => cn.type === "R");
1889
- const sNode = curveNodes.find((cn) => cn.type === "S");
1890
- const times = collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime);
1891
- if (times.length === 0) {
2586
+ const baseTimes = collectAnimationSampleTimes([...curveNodes, ...extraTimeCurves], fps, startTime, stopTime);
2587
+ if (baseTimes.length === 0) {
1892
2588
  return [];
1893
2589
  }
1894
- // Get curve accessors
1895
- const txCurve = tNode?.curves.find((c) => c.channel === "d|X");
1896
- const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y");
1897
- const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z");
1898
- const rxCurve = rNode?.curves.find((c) => c.channel === "d|X");
1899
- const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y");
1900
- const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z");
1901
- const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
1902
- const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
1903
- const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
1904
- // Build keyframes by computing the full matrix at each time
2590
+ const sampleTRS = (time) => {
2591
+ const sampled = this._sampleModelTRS(modelData, curveNodes, time);
2592
+ const trs = FBXFileLoader._computeLocalTRS(modelData, sampled.translation, sampled.rotation, sampled.scale);
2593
+ this._adjustTRSForInheritMode(modelData, trs, time);
2594
+ return trs;
2595
+ };
2596
+ const times = FBXFileLoader._refineSampleTimes(baseTimes, (time) => {
2597
+ const trs = sampleTRS(time);
2598
+ return Matrix.Compose(trs.scaling, trs.rotationQuaternion, trs.position);
2599
+ });
2600
+ // Build keyframes by computing the local transform at each time
1905
2601
  const posKeys = [];
1906
2602
  const rotKeys = [];
1907
2603
  const sclKeys = [];
1908
2604
  let prevQuat = null;
1909
2605
  for (const time of times) {
1910
2606
  const frame = time * fps;
1911
- // Sample animated values, falling back to model's base values
1912
- const tx = sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0];
1913
- const ty = sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1];
1914
- const tz = sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2];
1915
- const rx = sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0];
1916
- const ry = sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1];
1917
- const rz = sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2];
1918
- const sx = sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0];
1919
- const sy = sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1];
1920
- const sz = sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2];
1921
- // Compute the full FBX local transform matrix with pivots
1922
- const localMatrix = FBXFileLoader._computeFBXLocalMatrix([tx, ty, tz], [rx, ry, rz], [sx, sy, sz], modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
1923
- // Decompose into TRS
1924
- const s = new Vector3();
1925
- const r = new Quaternion();
1926
- const t = new Vector3();
1927
- localMatrix.decompose(s, r, t);
2607
+ const trs = sampleTRS(time);
2608
+ const s = trs.scaling;
2609
+ const r = trs.rotationQuaternion;
2610
+ const t = trs.position;
2611
+ const stepT = isChannelSteppedAt(curveNodes, "T", time);
2612
+ const stepR = isChannelSteppedAt(curveNodes, "R", time);
2613
+ const stepS = isChannelSteppedAt(curveNodes, "S", time);
1928
2614
  // Ensure quaternion continuity
1929
2615
  if (prevQuat && Quaternion.Dot(prevQuat, r) < 0) {
1930
2616
  r.scaleInPlace(-1);
1931
2617
  }
1932
2618
  prevQuat = r;
1933
- posKeys.push({ frame, value: t });
1934
- rotKeys.push({ frame, value: r });
1935
- sclKeys.push({ frame, value: s });
2619
+ posKeys.push(stepT ? { frame, value: t, interpolation: 1 /* AnimationKeyInterpolation.STEP */ } : { frame, value: t });
2620
+ rotKeys.push(stepR ? { frame, value: r, interpolation: 1 /* AnimationKeyInterpolation.STEP */ } : { frame, value: r });
2621
+ sclKeys.push(stepS ? { frame, value: s, interpolation: 1 /* AnimationKeyInterpolation.STEP */ } : { frame, value: s });
1936
2622
  }
1937
2623
  const animations = [];
1938
2624
  // Only create position animation if it's not constant
@@ -1955,6 +2641,404 @@ export class FBXFileLoader {
1955
2641
  }
1956
2642
  return animations;
1957
2643
  }
2644
+ /**
2645
+ * Baked keys are interpolated linearly by Babylon. Between two frames an FBX cubic segment can deviate from that
2646
+ * line, so sample times are refined (midpoints inserted, up to two levels) wherever the interpolated transform
2647
+ * differs noticeably from the curve. Flat and linear segments stay at frame resolution.
2648
+ */
2649
+ static _refineSampleTimes(times, sample, maxDepth = 2) {
2650
+ const decompose = (time) => {
2651
+ const s = new Vector3();
2652
+ const r = new Quaternion();
2653
+ const t = new Vector3();
2654
+ sample(time).decompose(s, r, t);
2655
+ return { s, r, t };
2656
+ };
2657
+ const out = [];
2658
+ const refine = (t0, t1, d0, d1, depth) => {
2659
+ if (depth >= maxDepth) {
2660
+ return;
2661
+ }
2662
+ const tm = (t0 + t1) * 0.5;
2663
+ if (!(tm > t0 && tm < t1)) {
2664
+ return;
2665
+ }
2666
+ const dm = decompose(tm);
2667
+ const lerpT = Vector3.Lerp(d0.t, d1.t, 0.5);
2668
+ const lerpS = Vector3.Lerp(d0.s, d1.s, 0.5);
2669
+ const slerpR = Quaternion.Slerp(d0.r, d1.r, 0.5);
2670
+ const posErr = Vector3.Distance(lerpT, dm.t);
2671
+ const sclErr = Vector3.Distance(lerpS, dm.s);
2672
+ const dot = Math.min(1, Math.abs(Quaternion.Dot(slerpR, dm.r)));
2673
+ const angErr = 2 * Math.acos(dot);
2674
+ const posTol = 1e-4 * (1 + Math.max(Math.abs(dm.t.x), Math.abs(dm.t.y), Math.abs(dm.t.z)));
2675
+ const sclTol = 1e-4 * (1 + Math.max(Math.abs(dm.s.x), Math.abs(dm.s.y), Math.abs(dm.s.z)));
2676
+ if (posErr > posTol || sclErr > sclTol || angErr > 0.0005) {
2677
+ refine(t0, tm, d0, dm, depth + 1);
2678
+ out.push(tm);
2679
+ refine(tm, t1, dm, d1, depth + 1);
2680
+ }
2681
+ };
2682
+ let prev = times.length > 0 ? decompose(times[0]) : null;
2683
+ for (let i = 0; i < times.length; i++) {
2684
+ out.push(times[i]);
2685
+ if (i + 1 < times.length && prev) {
2686
+ const next = decompose(times[i + 1]);
2687
+ refine(times[i], times[i + 1], prev, next, 0);
2688
+ prev = next;
2689
+ }
2690
+ }
2691
+ return out;
2692
+ }
2693
+ /**
2694
+ * Keys of a scalar property animated by one or more layers: the authored keys when a single layer drives it,
2695
+ * otherwise the frame grid evaluated through the layer stack.
2696
+ * @param sources - Per-layer curves of the property (each with at least one curve)
2697
+ * @param animStack - Stack being converted
2698
+ * @param mapValue - Conversion from the FBX value to the Babylon property value
2699
+ * @returns Animation keys
2700
+ */
2701
+ _layeredScalarKeys(sources, animStack, mapValue) {
2702
+ const fps = this._frameRate;
2703
+ if (sources.length === 0) {
2704
+ return [];
2705
+ }
2706
+ if (sources.length === 1) {
2707
+ return buildScalarAnimationKeys(sources[0].curves[0], fps, animStack.startTime, animStack.stopTime, mapValue);
2708
+ }
2709
+ const channel = sources[0].curves[0].channel;
2710
+ const times = collectAnimationSampleTimes(sources.map((source) => ({ type: "other", targetModelId: 0, curves: Array.from(source.curves), layerIndex: source.layerIndex })), fps, animStack.startTime, animStack.stopTime);
2711
+ return times.map((time) => ({ frame: time * fps, value: mapValue(evaluateLayeredProperty(sources, animStack.layers, [channel], [0], time)[0]) }));
2712
+ }
2713
+ /**
2714
+ * Maps an animated FBX property (anything other than node transforms and blend shape weights) onto the Babylon
2715
+ * property that carries it: mesh visibility, camera field of view and clip planes, light intensity, colour and
2716
+ * cone angles, and material colours, alpha, roughness and metalness. `group` holds the curve nodes of every
2717
+ * layer animating that property, in layer order; several layers are evaluated through the layer stack.
2718
+ */
2719
+ _buildPropertyAnimations(group, animStack, modelIdToNode, targets) {
2720
+ const fps = this._frameRate;
2721
+ const curveNode = group[0];
2722
+ const prop = curveNode.propertyName ?? curveNode.type;
2723
+ const targetId = curveNode.targetId;
2724
+ const out = [];
2725
+ const scalarCurve = curveNode.curves.find((c) => c.channel === "d|X") ?? curveNode.curves[0];
2726
+ const defaults = curveNode.defaultValues;
2727
+ const layered = group.length > 1;
2728
+ const sources = group.map((cn) => ({ layerIndex: cn.layerIndex, curves: cn.curves, defaultValues: cn.defaultValues }));
2729
+ const sampleTimes = () => collectAnimationSampleTimes(group.map((cn) => ({ type: "other", targetModelId: targetId, curves: cn.curves, layerIndex: cn.layerIndex })), fps, animStack.startTime, animStack.stopTime);
2730
+ const vectorSamples = (staticValue) => {
2731
+ const cx = curveNode.curves.find((c) => c.channel === "d|X");
2732
+ const cy = curveNode.curves.find((c) => c.channel === "d|Y");
2733
+ const cz = curveNode.curves.find((c) => c.channel === "d|Z");
2734
+ return sampleTimes().map((time) => ({
2735
+ frame: time * fps,
2736
+ values: layered
2737
+ ? evaluateLayeredProperty(sources, animStack.layers, ["d|X", "d|Y", "d|Z"], staticValue, time)
2738
+ : [sampleFBXCurveAtTime(cx, time) ?? staticValue[0], sampleFBXCurveAtTime(cy, time) ?? staticValue[1], sampleFBXCurveAtTime(cz, time) ?? staticValue[2]],
2739
+ }));
2740
+ };
2741
+ const scalarKeys = (map, step = false) => {
2742
+ const keys = layered
2743
+ ? sampleTimes().map((time) => ({
2744
+ frame: time * fps,
2745
+ value: map(evaluateLayeredProperty(sources, animStack.layers, [scalarCurve.channel], [defaults[scalarCurve.channel] ?? 0], time)[0]),
2746
+ }))
2747
+ : buildScalarAnimationKeys(scalarCurve, fps, animStack.startTime, animStack.stopTime, map);
2748
+ if (step) {
2749
+ for (const key of keys) {
2750
+ key.interpolation = 1 /* AnimationKeyInterpolation.STEP */;
2751
+ delete key.inTangent;
2752
+ delete key.outTangent;
2753
+ }
2754
+ }
2755
+ return keys;
2756
+ };
2757
+ const colorKeys = () => vectorSamples([defaults["d|X"] ?? 1, defaults["d|Y"] ?? 1, defaults["d|Z"] ?? 1]).map(({ frame, values }) => ({
2758
+ frame,
2759
+ value: new Color3(values[0], values[1], values[2]),
2760
+ }));
2761
+ const add = (target, property, type, keys, name) => {
2762
+ if (keys.length === 0) {
2763
+ return;
2764
+ }
2765
+ const animation = new Animation(`${name}_${property}`, property, fps, type, Animation.ANIMATIONLOOPMODE_CYCLE);
2766
+ animation.setKeys(keys);
2767
+ out.push({ animation, target });
2768
+ };
2769
+ const node = modelIdToNode.get(targetId);
2770
+ if (node) {
2771
+ if (prop === "Visibility" && node instanceof Mesh) {
2772
+ add(node, "visibility", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => Math.min(1, Math.max(0, v)), true), node.name);
2773
+ return out;
2774
+ }
2775
+ // Animated user properties drive the value stored in the node's metadata.
2776
+ const userProperties = node.metadata?.fbxUserProperties;
2777
+ if (userProperties && prop in userProperties) {
2778
+ const hasVector = curveNode.curves.some((c) => c.channel === "d|Y" || c.channel === "d|Z");
2779
+ if (hasVector) {
2780
+ const current = userProperties[prop];
2781
+ const base = Array.isArray(current) ? current : [0, 0, 0];
2782
+ userProperties[prop] = new Vector3(base[0] ?? 0, base[1] ?? 0, base[2] ?? 0);
2783
+ const keys = vectorSamples([base[0] ?? 0, base[1] ?? 0, base[2] ?? 0]).map(({ frame, values }) => ({
2784
+ frame,
2785
+ value: new Vector3(values[0], values[1], values[2]),
2786
+ }));
2787
+ add(node, `metadata.fbxUserProperties.${prop}`, Animation.ANIMATIONTYPE_VECTOR3, keys, node.name);
2788
+ }
2789
+ else {
2790
+ if (typeof userProperties[prop] === "boolean") {
2791
+ userProperties[prop] = userProperties[prop] ? 1 : 0;
2792
+ }
2793
+ add(node, `metadata.fbxUserProperties.${prop}`, Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => v), node.name);
2794
+ }
2795
+ }
2796
+ return out;
2797
+ }
2798
+ const camera = targets.cameraByAttributeId.get(targetId);
2799
+ if (camera) {
2800
+ const meta = camera.metadata?.fbxCamera;
2801
+ const aspect = meta?.aspectRatio || 1;
2802
+ const apertureMode = meta?.apertureMode ?? 2;
2803
+ switch (prop) {
2804
+ case "FieldOfView":
2805
+ // Vertical unless the camera's aperture mode declares FieldOfView as horizontal.
2806
+ add(camera, "fov", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => (apertureMode === 1 ? 2 * Math.atan(Math.tan((v * Math.PI) / 360) / aspect) : (v * Math.PI) / 180)), camera.name);
2807
+ break;
2808
+ case "FieldOfViewY":
2809
+ add(camera, "fov", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => (v * Math.PI) / 180), camera.name);
2810
+ break;
2811
+ case "FieldOfViewX":
2812
+ if (apertureMode !== 0) {
2813
+ add(camera, "fov", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => 2 * Math.atan(Math.tan((v * Math.PI) / 360) / aspect)), camera.name);
2814
+ }
2815
+ break;
2816
+ case "FocalLength": {
2817
+ const apertureY = meta?.apertureSizeInch?.[1];
2818
+ if (apertureY && apertureMode === 3) {
2819
+ add(camera, "fov", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => 2 * Math.atan((apertureY / Math.max(v / 25.4, 1e-6)) * 0.5)), camera.name);
2820
+ }
2821
+ break;
2822
+ }
2823
+ case "NearPlane":
2824
+ add(camera, "minZ", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => v), camera.name);
2825
+ break;
2826
+ case "FarPlane":
2827
+ add(camera, "maxZ", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => v), camera.name);
2828
+ break;
2829
+ default:
2830
+ break;
2831
+ }
2832
+ return out;
2833
+ }
2834
+ const light = targets.lightByAttributeId.get(targetId);
2835
+ if (light) {
2836
+ switch (prop) {
2837
+ case "Intensity":
2838
+ add(light, "intensity", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => v / 100), light.name);
2839
+ break;
2840
+ case "Color":
2841
+ add(light, "diffuse", Animation.ANIMATIONTYPE_COLOR3, colorKeys(), light.name);
2842
+ break;
2843
+ case "OuterAngle":
2844
+ case "ConeAngle":
2845
+ if (light instanceof SpotLight) {
2846
+ add(light, "angle", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => (v * Math.PI) / 180), light.name);
2847
+ }
2848
+ break;
2849
+ case "InnerAngle":
2850
+ if (light instanceof SpotLight) {
2851
+ add(light, "innerAngle", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => (v * Math.PI) / 180), light.name);
2852
+ }
2853
+ break;
2854
+ default:
2855
+ break;
2856
+ }
2857
+ return out;
2858
+ }
2859
+ const material = targets.materialCache.get(targetId);
2860
+ if (material) {
2861
+ const isPbr = material instanceof PBRMaterial;
2862
+ const short = prop.substring(prop.lastIndexOf("|") + 1);
2863
+ if (prop === "DiffuseColor" || prop === "Diffuse" || short === "baseColor" || short === "base_color" || short === "color") {
2864
+ add(material, isPbr ? "albedoColor" : "diffuseColor", Animation.ANIMATIONTYPE_COLOR3, colorKeys(), material.name);
2865
+ }
2866
+ else if (prop === "EmissiveColor" ||
2867
+ prop === "Emissive" ||
2868
+ short === "emissionColor" ||
2869
+ short === "emission_color" ||
2870
+ short === "emissive" ||
2871
+ short === "emit_color") {
2872
+ add(material, "emissiveColor", Animation.ANIMATIONTYPE_COLOR3, colorKeys(), material.name);
2873
+ }
2874
+ else if (prop === "Opacity") {
2875
+ add(material, "alpha", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => v), material.name);
2876
+ }
2877
+ else if (prop === "TransparencyFactor" || prop === "TransparentFactor") {
2878
+ add(material, "alpha", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => 1 - v), material.name);
2879
+ }
2880
+ else if (isPbr && (short === "roughness" || short === "specular_roughness" || short === "specularRoughness")) {
2881
+ add(material, "roughness", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => v), material.name);
2882
+ }
2883
+ else if (isPbr && (short === "metalness" || short === "metallic" || short === "base_metalness")) {
2884
+ add(material, "metallic", Animation.ANIMATIONTYPE_FLOAT, scalarKeys((v) => v), material.name);
2885
+ }
2886
+ else if (!isPbr && prop === "SpecularColor") {
2887
+ add(material, "specularColor", Animation.ANIMATIONTYPE_COLOR3, colorKeys(), material.name);
2888
+ }
2889
+ return out;
2890
+ }
2891
+ return out;
2892
+ }
2893
+ /** Records constraints on their nodes and, unless disabled, attaches the runtime behavior that solves them. */
2894
+ _applyConstraints(fbxScene, modelIdToNode, rootNode, scene) {
2895
+ const nodeName = (id) => (id === undefined ? undefined : modelIdToNode.get(id)?.name);
2896
+ const upSign = fbxScene.upAxisSign >= 0 ? 1 : -1;
2897
+ const sceneUp = new Vector3(fbxScene.upAxis === 0 ? upSign : 0, fbxScene.upAxis === 1 ? upSign : 0, fbxScene.upAxis === 2 ? upSign : 0);
2898
+ const summaries = [];
2899
+ for (const c of fbxScene.constraints) {
2900
+ const summary = {
2901
+ name: c.name,
2902
+ type: c.type,
2903
+ node: nodeName(c.nodeId),
2904
+ targets: c.targets.map((t) => ({ node: nodeName(t.modelId), weight: t.weight })),
2905
+ weight: c.weight,
2906
+ active: c.active,
2907
+ ...(c.type === "singleChainIK" ? { firstJoint: nodeName(c.ikFirstJointId), endJoint: nodeName(c.ikEndJointId), effector: nodeName(c.ikEffectorId) } : {}),
2908
+ };
2909
+ summaries.push(summary);
2910
+ const node = c.nodeId !== undefined ? modelIdToNode.get(c.nodeId) : undefined;
2911
+ if (!node) {
2912
+ continue;
2913
+ }
2914
+ const existing = (node.metadata?.fbxConstraints ?? []);
2915
+ node.metadata = { ...(node.metadata ?? {}), fbxConstraints: [...existing, summary] };
2916
+ if (this._options.constraints !== "apply" || c.type === "singleChainIK" || c.type === "unknown") {
2917
+ continue;
2918
+ }
2919
+ const targets = [];
2920
+ for (const t of c.targets) {
2921
+ const targetNode = modelIdToNode.get(t.modelId);
2922
+ if (!targetNode) {
2923
+ continue;
2924
+ }
2925
+ const d2r = Math.PI / 180;
2926
+ const offset = c.type === "parent"
2927
+ ? Matrix.Compose(new Vector3(...t.offsetScale), Quaternion.FromRotationMatrix(eulerToMatrixXYZ(t.offsetRotation[0] * d2r, t.offsetRotation[1] * d2r, t.offsetRotation[2] * d2r)), new Vector3(...t.offsetTranslation))
2928
+ : Matrix.Identity();
2929
+ targets.push({ node: targetNode, weight: t.weight, offset });
2930
+ }
2931
+ if (targets.length === 0) {
2932
+ continue;
2933
+ }
2934
+ const upNode = c.worldUpNodeId !== undefined ? (modelIdToNode.get(c.worldUpNodeId) ?? null) : null;
2935
+ node.addBehavior(new FBXConstraintBehavior(c, { root: rootNode, targets, upNode, sceneUp }));
2936
+ }
2937
+ if (summaries.length > 0) {
2938
+ rootNode.metadata = { ...(rootNode.metadata ?? {}), fbxConstraints: summaries };
2939
+ }
2940
+ }
2941
+ /** Collects every recoverable issue the interpreter recorded, stores it on the root node and notifies the caller. */
2942
+ _reportDiagnostics(fbxScene, rootNode, modelIdToNode) {
2943
+ const warnings = [];
2944
+ const push = (source, d, objectName) => {
2945
+ const message = typeof d === "string" ? d : (d?.message ?? JSON.stringify(d));
2946
+ warnings.push({ source, message, objectName, details: typeof d === "string" ? undefined : d });
2947
+ };
2948
+ for (const d of fbxScene.diagnostics) {
2949
+ // Objects with several parents (shared materials, textures, instanced geometry) are normal in FBX.
2950
+ if (d.subType === "duplicate-parent") {
2951
+ continue;
2952
+ }
2953
+ push("scene", d, d.objectName ?? d.nodeName);
2954
+ }
2955
+ for (const g of fbxScene.geometries) {
2956
+ for (const d of g.diagnostics) {
2957
+ push("geometry", d, g.name);
2958
+ }
2959
+ }
2960
+ for (const c of fbxScene.curves) {
2961
+ for (const d of c.diagnostics) {
2962
+ push("geometry", d, c.name);
2963
+ }
2964
+ }
2965
+ for (const skin of fbxScene.skins) {
2966
+ for (const d of skin.diagnostics) {
2967
+ push("skin", d);
2968
+ }
2969
+ for (const bone of skin.bones) {
2970
+ for (const d of bone.diagnostics) {
2971
+ push("skin", d, bone.name);
2972
+ }
2973
+ }
2974
+ }
2975
+ for (const rig of fbxScene.rigs) {
2976
+ for (const w of rig.warnings) {
2977
+ push("rig", w);
2978
+ }
2979
+ }
2980
+ for (const stack of fbxScene.animations) {
2981
+ for (const d of stack.diagnostics) {
2982
+ if (d.type === "unsupported-curve-node" && d.curveNodeId !== undefined && this._evaluatedCurveNodeIds.has(d.curveNodeId)) {
2983
+ continue;
2984
+ }
2985
+ push("animation", d, stack.name);
2986
+ }
2987
+ }
2988
+ for (const cam of fbxScene.cameras) {
2989
+ for (const d of cam.diagnostics) {
2990
+ push("camera", d, cam.name);
2991
+ }
2992
+ }
2993
+ for (const light of fbxScene.lights) {
2994
+ for (const d of light.diagnostics) {
2995
+ push("light", d, light.name);
2996
+ }
2997
+ }
2998
+ const walk = (models) => {
2999
+ for (const m of models) {
3000
+ for (const d of m.diagnostics) {
3001
+ push("model", d, m.name);
3002
+ }
3003
+ walk(m.children);
3004
+ }
3005
+ };
3006
+ walk(fbxScene.rootModels);
3007
+ rootNode.metadata = {
3008
+ ...(rootNode.metadata ?? {}),
3009
+ fbxDiagnostics: warnings,
3010
+ fbxDisplayLayers: fbxScene.displayLayers.map((l) => ({ name: l.name, show: l.show, freeze: l.freeze, color: l.color })),
3011
+ fbxSelectionSets: fbxScene.selectionSets.map((set) => ({
3012
+ name: set.name,
3013
+ members: set.members.map((m) => ({
3014
+ node: modelIdToNode.get(m.modelId)?.name ?? String(m.modelId),
3015
+ includeNode: m.includeNode,
3016
+ ...(m.vertices ? { vertices: m.vertices } : {}),
3017
+ ...(m.edges ? { edges: m.edges } : {}),
3018
+ ...(m.faces ? { faces: m.faces } : {}),
3019
+ })),
3020
+ })),
3021
+ };
3022
+ for (const warning of warnings) {
3023
+ this._options.onWarning(warning);
3024
+ }
3025
+ }
3026
+ /** Curve nodes affecting the inherit scale of a model: its own scale curves and those of its inherit-scale chain. */
3027
+ _collectInheritScaleCurves(modelData) {
3028
+ const result = [];
3029
+ let current = modelData;
3030
+ const seen = new Set();
3031
+ while (current && !seen.has(current.id)) {
3032
+ seen.add(current.id);
3033
+ for (const cn of this._curveNodesByModelId.get(current.id) ?? []) {
3034
+ if (cn.type === "S") {
3035
+ result.push(cn);
3036
+ }
3037
+ }
3038
+ current = this._getInheritScaleNode(current);
3039
+ }
3040
+ return result;
3041
+ }
1958
3042
  _isVector3KeysConstant(keys) {
1959
3043
  if (keys.length < 2) {
1960
3044
  return true;
@@ -1968,43 +3052,126 @@ export class FBXFileLoader {
1968
3052
  }
1969
3053
  return true;
1970
3054
  }
3055
+ /** Samples the animated Lcl Translation / Rotation / Scaling of a model, blending all layers of the active stack. */
3056
+ _sampleModelTRS(modelData, curveNodes, time) {
3057
+ const layers = this._activeLayers;
3058
+ return {
3059
+ translation: evaluateLayeredChannel(curveNodes, layers, "T", modelData.translation, modelData.rotationOrder, time),
3060
+ rotation: evaluateLayeredChannel(curveNodes, layers, "R", modelData.rotation, modelData.rotationOrder, time),
3061
+ scale: evaluateLayeredChannel(curveNodes, layers, "S", modelData.scale, modelData.rotationOrder, time),
3062
+ };
3063
+ }
3064
+ /**
3065
+ * Local position/rotation/scale of a model from FBX Lcl values. Without pivots and offsets the components map
3066
+ * directly (rotation = pre * lcl * post⁻¹), which keeps zero and negative scales exact. With pivots the full
3067
+ * matrix is built and decomposed.
3068
+ */
3069
+ static _computeLocalTRS(modelData, translation, rotation, scale) {
3070
+ const nz = (v) => v[0] !== 0 || v[1] !== 0 || v[2] !== 0;
3071
+ const d2r = Math.PI / 180;
3072
+ let rotM = eulerToMatrix(rotation[0] * d2r, rotation[1] * d2r, rotation[2] * d2r, modelData.rotationOrder);
3073
+ if (nz(modelData.preRotation)) {
3074
+ rotM = rotM.multiply(eulerToMatrixXYZ(modelData.preRotation[0] * d2r, modelData.preRotation[1] * d2r, modelData.preRotation[2] * d2r));
3075
+ }
3076
+ if (nz(modelData.postRotation)) {
3077
+ const post = eulerToMatrixXYZ(modelData.postRotation[0] * d2r, modelData.postRotation[1] * d2r, modelData.postRotation[2] * d2r);
3078
+ const postInv = new Matrix();
3079
+ post.invertToRef(postInv);
3080
+ rotM = postInv.multiply(rotM);
3081
+ }
3082
+ let position = new Vector3(translation[0], translation[1], translation[2]);
3083
+ if (nz(modelData.rotationPivot) || nz(modelData.scalingPivot) || nz(modelData.rotationOffset) || nz(modelData.scalingOffset)) {
3084
+ // Pivots and offsets only move the translation: the linear part of the FBX local matrix is always S·R.
3085
+ position = FBXFileLoader._computeFBXLocalMatrix(translation, rotation, scale, modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder).getTranslation();
3086
+ }
3087
+ return {
3088
+ position,
3089
+ rotationQuaternion: Quaternion.FromRotationMatrix(rotM),
3090
+ scaling: new Vector3(scale[0], scale[1], scale[2]),
3091
+ };
3092
+ }
3093
+ /** Applies inherit-mode adjustments to a local TRS (see _computeInheritAwareLocalMatrix). */
3094
+ _adjustTRSForInheritMode(modelData, trs, time) {
3095
+ const parent = this._parentModelById.get(modelData.id);
3096
+ if (modelData.inheritType === 1 || !parent) {
3097
+ return;
3098
+ }
3099
+ const parentScale = this._getInheritScale(parent, time);
3100
+ trs.position.x *= parentScale[0];
3101
+ trs.position.y *= parentScale[1];
3102
+ trs.position.z *= parentScale[2];
3103
+ const inheritNode = this._getInheritScaleNode(modelData);
3104
+ if (inheritNode) {
3105
+ const inherited = this._getInheritScale(inheritNode, time);
3106
+ trs.scaling.x *= inherited[0];
3107
+ trs.scaling.y *= inherited[1];
3108
+ trs.scaling.z *= inherited[2];
3109
+ }
3110
+ }
1971
3111
  _sampleModelLocalMatrix(modelData, curveNodes, time, scaleOverride) {
1972
- const tNode = curveNodes.find((cn) => cn.type === "T");
1973
- const rNode = curveNodes.find((cn) => cn.type === "R");
1974
- const sNode = curveNodes.find((cn) => cn.type === "S");
1975
- const txCurve = tNode?.curves.find((c) => c.channel === "d|X");
1976
- const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y");
1977
- const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z");
1978
- const rxCurve = rNode?.curves.find((c) => c.channel === "d|X");
1979
- const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y");
1980
- const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z");
1981
- const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
1982
- const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
1983
- const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
1984
- return FBXFileLoader._computeFBXLocalMatrix([
1985
- sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0],
1986
- sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1],
1987
- sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2],
1988
- ], [
1989
- sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0],
1990
- sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1],
1991
- sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2],
1992
- ], scaleOverride ?? [
1993
- sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0],
1994
- sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1],
1995
- sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2],
1996
- ], modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
3112
+ const trs = this._sampleModelTRS(modelData, curveNodes, time);
3113
+ return FBXFileLoader._computeFBXLocalMatrix(trs.translation, trs.rotation, scaleOverride ?? trs.scale, modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
1997
3114
  }
1998
3115
  _sampleModelScale(modelData, curveNodes, time) {
1999
- const sNode = curveNodes.find((cn) => cn.type === "S");
2000
- const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
2001
- const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
2002
- const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
2003
- return [
2004
- sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0],
2005
- sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1],
2006
- sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2],
2007
- ];
3116
+ return evaluateLayeredChannel(curveNodes, this._activeLayers, "S", modelData.scale, modelData.rotationOrder, time);
3117
+ }
3118
+ /**
3119
+ * Effective scale of a model for inherit-mode math (`inherit_scale` in ufbx terms): its own local scale, multiplied
3120
+ * componentwise by the inherited scale when the model uses RrSs inheritance. `time` samples animation; undefined
3121
+ * uses the rest pose.
3122
+ */
3123
+ _getInheritScale(modelData, time) {
3124
+ if (!modelData) {
3125
+ return [1, 1, 1];
3126
+ }
3127
+ const own = time === undefined ? modelData.scale : this._sampleModelScale(modelData, this._curveNodesByModelId.get(modelData.id) ?? [], time);
3128
+ const inheritNode = this._getInheritScaleNode(modelData);
3129
+ if (!inheritNode) {
3130
+ return [own[0], own[1], own[2]];
3131
+ }
3132
+ const parentScale = this._getInheritScale(inheritNode, time);
3133
+ return [own[0] * parentScale[0], own[1] * parentScale[1], own[2] * parentScale[2]];
3134
+ }
3135
+ /** RrSs nodes inherit scale from their parent; Rrs nodes skip their immediate parent (chaining through Rrs parents). */
3136
+ _getInheritScaleNode(modelData) {
3137
+ const parent = this._parentModelById.get(modelData.id);
3138
+ if (!parent) {
3139
+ return undefined;
3140
+ }
3141
+ if (modelData.inheritType === 0) {
3142
+ return parent;
3143
+ }
3144
+ if (modelData.inheritType === 2) {
3145
+ return this._getInheritScaleNode(parent);
3146
+ }
3147
+ return undefined;
3148
+ }
3149
+ /**
3150
+ * Local matrix of a model relative to its Babylon parent frame, accounting for inherit modes. For RSrs (the
3151
+ * default) this is the FBX local matrix. For RrSs / Rrs the node sits under a helper that removes the parent's
3152
+ * scale, so translation is pre-scaled by the parent scale and (for RrSs) scale accumulates componentwise.
3153
+ */
3154
+ _computeInheritAwareLocalMatrix(modelData, localMatrix, time) {
3155
+ const parent = this._parentModelById.get(modelData.id);
3156
+ if (modelData.inheritType === 1 || !parent) {
3157
+ return localMatrix;
3158
+ }
3159
+ const s = new Vector3();
3160
+ const r = new Quaternion();
3161
+ const t = new Vector3();
3162
+ localMatrix.decompose(s, r, t);
3163
+ const parentScale = this._getInheritScale(parent, time);
3164
+ t.x *= parentScale[0];
3165
+ t.y *= parentScale[1];
3166
+ t.z *= parentScale[2];
3167
+ const inheritNode = this._getInheritScaleNode(modelData);
3168
+ if (inheritNode) {
3169
+ const inherited = this._getInheritScale(inheritNode, time);
3170
+ s.x *= inherited[0];
3171
+ s.y *= inherited[1];
3172
+ s.z *= inherited[2];
3173
+ }
3174
+ return Matrix.Compose(s, r, t);
2008
3175
  }
2009
3176
  /**
2010
3177
  * Build matrix-baked bone animation from full FBX local transforms.
@@ -2012,25 +3179,13 @@ export class FBXFileLoader {
2012
3179
  * the same FBX local transform chain as the source skeleton.
2013
3180
  */
2014
3181
  _buildBoneAnimations(curveNodes, boneName, modelData, startTime, stopTime, bindLocalMatrix) {
2015
- const fps = 30;
2016
- // Separate curves by type
2017
- const tNode = curveNodes.find((cn) => cn.type === "T");
3182
+ const fps = this._frameRate;
2018
3183
  const rNode = curveNodes.find((cn) => cn.type === "R");
2019
- const sNode = curveNodes.find((cn) => cn.type === "S");
2020
- const times = collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime);
2021
- if (times.length === 0) {
3184
+ const baseTimes = collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime);
3185
+ if (baseTimes.length === 0) {
2022
3186
  return [];
2023
3187
  }
2024
- // Get curve accessors
2025
- const txCurve = tNode?.curves.find((c) => c.channel === "d|X");
2026
- const tyCurve = tNode?.curves.find((c) => c.channel === "d|Y");
2027
- const tzCurve = tNode?.curves.find((c) => c.channel === "d|Z");
2028
- const rxCurve = rNode?.curves.find((c) => c.channel === "d|X");
2029
- const ryCurve = rNode?.curves.find((c) => c.channel === "d|Y");
2030
- const rzCurve = rNode?.curves.find((c) => c.channel === "d|Z");
2031
- const sxCurve = sNode?.curves.find((c) => c.channel === "d|X");
2032
- const syCurve = sNode?.curves.find((c) => c.channel === "d|Y");
2033
- const szCurve = sNode?.curves.find((c) => c.channel === "d|Z");
3188
+ const times = FBXFileLoader._refineSampleTimes(baseTimes, (time) => this._sampleModelLocalMatrix(modelData, curveNodes, time));
2034
3189
  const posKeys = [];
2035
3190
  const rotKeys = [];
2036
3191
  const sclKeys = [];
@@ -2043,18 +3198,8 @@ export class FBXFileLoader {
2043
3198
  }
2044
3199
  for (const time of times) {
2045
3200
  const frame = time * fps;
2046
- // Sample animated values, falling back to model's base values
2047
- const tx = sampleFBXCurveAtTime(txCurve, time) ?? modelData.translation[0];
2048
- const ty = sampleFBXCurveAtTime(tyCurve, time) ?? modelData.translation[1];
2049
- const tz = sampleFBXCurveAtTime(tzCurve, time) ?? modelData.translation[2];
2050
- const rx = sampleFBXCurveAtTime(rxCurve, time) ?? modelData.rotation[0];
2051
- const ry = sampleFBXCurveAtTime(ryCurve, time) ?? modelData.rotation[1];
2052
- const rz = sampleFBXCurveAtTime(rzCurve, time) ?? modelData.rotation[2];
2053
- const sx = sampleFBXCurveAtTime(sxCurve, time) ?? modelData.scale[0];
2054
- const sy = sampleFBXCurveAtTime(syCurve, time) ?? modelData.scale[1];
2055
- const sz = sampleFBXCurveAtTime(szCurve, time) ?? modelData.scale[2];
2056
- // Compute the full FBX local matrix from animated Lcl values
2057
- const localMatrix = FBXFileLoader._computeFBXLocalMatrix([tx, ty, tz], [rx, ry, rz], [sx, sy, sz], modelData.preRotation, modelData.postRotation, modelData.rotationPivot, modelData.scalingPivot, modelData.rotationOffset, modelData.scalingOffset, modelData.rotationOrder);
3201
+ // Compute the full FBX local matrix from animated Lcl values, blending all layers
3202
+ const localMatrix = this._sampleModelLocalMatrix(modelData, curveNodes, time);
2058
3203
  const correctedLocalMatrix = restLocalInverse && bindLocalMatrix ? bindLocalMatrix.multiply(restLocalInverse).multiply(localMatrix) : localMatrix;
2059
3204
  const s = new Vector3();
2060
3205
  const r = new Quaternion();
@@ -2372,17 +3517,31 @@ function calculateBlendShapeInfluences(deformPercent, fullWeights, shapeCount) {
2372
3517
  function clamp01(value) {
2373
3518
  return Math.max(0, Math.min(1, value));
2374
3519
  }
3520
+ /** Interior frames kept between two keys when the segment is cubic or extrapolated. */
3521
+ const MAX_DENSE_SEGMENT_SAMPLES = 512;
3522
+ /** Interior frames kept between two keys when every curve is linear or constant there. */
3523
+ const MAX_LINEAR_SEGMENT_SAMPLES = 32;
3524
+ /** Upper bound on samples per animated node, beyond which the frame stride grows. */
3525
+ const MAX_SAMPLES_PER_TARGET = 100000;
3526
+ /**
3527
+ * Times at which the animation is baked: every key time plus the frame grid between keys. Segments where every
3528
+ * curve is linear or constant only get a few interior frames (Babylon interpolates linearly between the baked
3529
+ * keys), cubic or extrapolated segments get the full frame grid up to a cap, so day-long clips with a handful of
3530
+ * keys do not explode into millions of samples.
3531
+ */
2375
3532
  function collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime) {
2376
3533
  let minTime = Number.POSITIVE_INFINITY;
2377
3534
  let maxTime = Number.NEGATIVE_INFINITY;
2378
- const sourceTimes = new Set();
3535
+ const anchorSet = new Set();
3536
+ const curves = [];
2379
3537
  for (const curveNode of curveNodes) {
2380
3538
  for (const curve of curveNode.curves) {
3539
+ curves.push(curve);
2381
3540
  for (const key of curve.keys) {
2382
3541
  minTime = Math.min(minTime, key.time);
2383
3542
  maxTime = Math.max(maxTime, key.time);
2384
3543
  if (key.time >= startTime && key.time <= stopTime) {
2385
- sourceTimes.add(key.time);
3544
+ anchorSet.add(key.time);
2386
3545
  }
2387
3546
  }
2388
3547
  }
@@ -2392,13 +3551,81 @@ function collectAnimationSampleTimes(curveNodes, fps, startTime, stopTime) {
2392
3551
  }
2393
3552
  const rangeStart = stopTime > startTime ? startTime : minTime;
2394
3553
  const rangeStop = stopTime > startTime ? stopTime : maxTime;
2395
- const times = new Set([rangeStart, rangeStop, ...Array.from(sourceTimes)]);
2396
- const startFrame = Math.ceil(rangeStart * fps);
2397
- const stopFrame = Math.floor(rangeStop * fps);
2398
- for (let frame = startFrame; frame <= stopFrame; frame++) {
2399
- times.add(frame / fps);
3554
+ anchorSet.add(rangeStart);
3555
+ anchorSet.add(rangeStop);
3556
+ const anchors = Array.from(anchorSet)
3557
+ .filter((t) => t >= rangeStart && t <= rangeStop)
3558
+ .sort((a, b) => a - b);
3559
+ // A segment needs the dense grid when any curve is cubic there or extrapolates a non-constant pattern.
3560
+ const segmentIsDense = (t0) => {
3561
+ for (const curve of curves) {
3562
+ const keys = curve.keys;
3563
+ if (keys.length === 0) {
3564
+ continue;
3565
+ }
3566
+ if (t0 < keys[0].time) {
3567
+ if (curve.preExtrapolation && curve.preExtrapolation.mode !== "constant") {
3568
+ return true;
3569
+ }
3570
+ continue;
3571
+ }
3572
+ if (t0 >= keys[keys.length - 1].time) {
3573
+ if (curve.postExtrapolation && curve.postExtrapolation.mode !== "constant") {
3574
+ return true;
3575
+ }
3576
+ continue;
3577
+ }
3578
+ let lo = 0;
3579
+ let hi = keys.length - 1;
3580
+ while (hi - lo > 1) {
3581
+ const mid = (lo + hi) >> 1;
3582
+ if (keys[mid].time <= t0) {
3583
+ lo = mid;
3584
+ }
3585
+ else {
3586
+ hi = mid;
3587
+ }
3588
+ }
3589
+ if (keys[lo].interpolation === "cubic") {
3590
+ return true;
3591
+ }
3592
+ }
3593
+ return false;
3594
+ };
3595
+ const times = [];
3596
+ const totalFrames = Math.floor(rangeStop * fps) - Math.ceil(rangeStart * fps) + 1;
3597
+ const stride = Math.max(1, Math.ceil(totalFrames / MAX_SAMPLES_PER_TARGET));
3598
+ for (let i = 0; i < anchors.length; i++) {
3599
+ const t0 = anchors[i];
3600
+ times.push(t0);
3601
+ if (i + 1 >= anchors.length) {
3602
+ break;
3603
+ }
3604
+ const t1 = anchors[i + 1];
3605
+ const firstFrame = Math.floor(t0 * fps) + 1;
3606
+ const lastFrame = Math.ceil(t1 * fps) - 1;
3607
+ const interior = lastFrame - firstFrame + 1;
3608
+ if (interior <= 0) {
3609
+ continue;
3610
+ }
3611
+ const cap = segmentIsDense(t0) ? MAX_DENSE_SEGMENT_SAMPLES : MAX_LINEAR_SEGMENT_SAMPLES;
3612
+ const step = Math.max(stride, Math.ceil(interior / cap));
3613
+ for (let frame = firstFrame; frame <= lastFrame; frame += step) {
3614
+ times.push(frame / fps);
3615
+ }
3616
+ }
3617
+ const sorted = times.sort((a, b) => a - b);
3618
+ // Key times and frame times can differ by floating point noise; keep one sample per instant.
3619
+ const deduped = [];
3620
+ for (const t of sorted) {
3621
+ if (t < rangeStart - 1e-9 || t > rangeStop + 1e-9) {
3622
+ continue;
3623
+ }
3624
+ if (deduped.length === 0 || t - deduped[deduped.length - 1] > 1e-9) {
3625
+ deduped.push(t);
3626
+ }
2400
3627
  }
2401
- return Array.from(times).sort((a, b) => a - b);
3628
+ return deduped;
2402
3629
  }
2403
3630
  function areQuaternionKeysConstant(keys) {
2404
3631
  if (keys.length < 2) {
@@ -2428,6 +3655,10 @@ function buildScalarAnimationKeys(curve, fps, startTime, stopTime, mapValue) {
2428
3655
  time: range.start,
2429
3656
  value: sampleFBXCurveAtTime(curve, range.start) ?? 0,
2430
3657
  interpolation: "linear",
3658
+ leftDx: 0,
3659
+ leftDy: 0,
3660
+ rightDx: 0,
3661
+ rightDy: 0,
2431
3662
  },
2432
3663
  frame: range.start * fps,
2433
3664
  value: mapValue(sampleFBXCurveAtTime(curve, range.start) ?? 0),
@@ -2439,6 +3670,10 @@ function buildScalarAnimationKeys(curve, fps, startTime, stopTime, mapValue) {
2439
3670
  time: range.stop,
2440
3671
  value: sampleFBXCurveAtTime(curve, range.stop) ?? 0,
2441
3672
  interpolation: "linear",
3673
+ leftDx: 0,
3674
+ leftDy: 0,
3675
+ rightDx: 0,
3676
+ rightDy: 0,
2442
3677
  },
2443
3678
  frame: range.stop * fps,
2444
3679
  value: mapValue(sampleFBXCurveAtTime(curve, range.stop) ?? 0),