@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,18 +1,38 @@
1
1
  /* eslint-disable @typescript-eslint/naming-convention, jsdoc/require-param, jsdoc/require-returns */
2
- import { findDocumentNode, findChildByName, getPropertyValue, cleanFBXName } from "../types/fbxTypes.js";
2
+ import { extractNodeTransform } from "./nodeTransform.js";
3
+ import { extractUserProperties, getPropertyEntries, extractPropertyTemplates, getPropertyTemplate, resolveNumberProperty, resolvePropertyValue, resolveVector3Property, } from "./propertyTemplates.js";
4
+ import { findDocumentNode, findChildByName, getPropertyValue, cleanFBXName, getNodeArray } from "../types/fbxTypes.js";
3
5
  import { resolveConnections, getChildren } from "./connections.js";
4
6
  import { extractGeometry } from "./geometry.js";
5
- import { extractMaterial } from "./materials.js";
7
+ import { extractLineGeometry, extractNurbsCurveGeometry, nurbsSurfaceToGeometry } from "./nurbs.js";
8
+ import { isLegacyDocument, upgradeLegacyDocument } from "./legacyDocument.js";
9
+ import { extractConstraints } from "./constraints.js";
10
+ import { extractMaterial, extractTextureRef } from "./materials.js";
6
11
  import { extractSkins } from "./skeleton.js";
7
12
  import { resolveRigs } from "./rig.js";
8
13
  import { extractAnimations } from "./animation.js";
9
14
  import { extractBlendShapes } from "./blendShapes.js";
10
15
  import { extractSceneDiagnostics } from "./sceneDiagnostics.js";
11
- import { extractPropertyTemplates, getPropertyTemplate, resolveNumberProperty, resolvePropertyValue, resolveVector3Property, } from "./propertyTemplates.js";
12
16
  /**
13
- * Interpret a parsed FBX document into scene data.
17
+ * Tessellated NURBS vertices carry no control point mapping, so skin clusters and blend shapes bound to the surface
18
+ * cannot be applied; they are reported and the surface is imported undeformed.
14
19
  */
15
- export function interpretFBX(doc) {
20
+ function flagNurbsDeformers(objectMap, geometryId, geometry) {
21
+ const kinds = getChildren(objectMap, geometryId, "Deformer")
22
+ .map((d) => getPropertyValue(d.node, 2) ?? "")
23
+ .filter((kind) => kind === "Skin" || kind === "BlendShape");
24
+ if (kinds.length === 0) {
25
+ return;
26
+ }
27
+ geometry.diagnostics.push({
28
+ type: "nurbs-deformer-ignored",
29
+ message: `${Array.from(new Set(kinds)).join(" and ")} deformer on NURBS surface '${geometry.name}' is ignored: the tessellated mesh has no control point mapping.`,
30
+ });
31
+ }
32
+ export function interpretFBX(doc, options = {}) {
33
+ if (isLegacyDocument(doc)) {
34
+ doc = upgradeLegacyDocument(doc);
35
+ }
16
36
  const objectMap = resolveConnections(doc);
17
37
  const propertyTemplates = extractPropertyTemplates(doc);
18
38
  // Extract global settings
@@ -24,32 +44,70 @@ export function interpretFBX(doc) {
24
44
  materials.push(extractMaterial(node, id, objectMap, propertyTemplates));
25
45
  }
26
46
  }
47
+ attachLegacyLayerTextures(objectMap, materials, propertyTemplates);
27
48
  // Extract all geometries
28
49
  const geometries = [];
50
+ const curves = [];
29
51
  for (const [id, node] of Array.from(objectMap.objects)) {
30
52
  if (node.name === "Geometry") {
31
53
  const subType = getPropertyValue(node, 2);
32
54
  if (subType === "Mesh") {
33
- geometries.push(extractGeometry(node, id));
55
+ const geometry = extractGeometry(node, id);
56
+ // Edge-only or empty meshes have nothing to render; keep the node but drop the mesh.
57
+ if (geometry.positions.length > 0 && geometry.indices.length > 0) {
58
+ geometries.push(geometry);
59
+ }
60
+ }
61
+ else if (subType === "NurbsSurface") {
62
+ const geometry = nurbsSurfaceToGeometry(node, id, options.nurbsSubdivision);
63
+ if (geometry) {
64
+ flagNurbsDeformers(objectMap, id, geometry);
65
+ geometries.push(geometry);
66
+ }
67
+ }
68
+ else if (subType === "TrimNurbsSurface") {
69
+ // The trimmed surface is rendered untrimmed: the underlying NurbsSurface is a child of the trim.
70
+ const surfaceChild = getChildren(objectMap, id, "Geometry").find((child) => getPropertyValue(child.node, 2) === "NurbsSurface");
71
+ const geometry = surfaceChild ? nurbsSurfaceToGeometry(surfaceChild.node, id, options.nurbsSubdivision) : null;
72
+ if (geometry) {
73
+ flagNurbsDeformers(objectMap, id, geometry);
74
+ geometry.diagnostics.push({
75
+ type: "nurbs-trim-ignored",
76
+ message: `Trim curves of NURBS surface '${geometry.name}' are ignored; the surface is rendered untrimmed.`,
77
+ });
78
+ geometries.push(geometry);
79
+ }
80
+ }
81
+ else if (subType === "NurbsCurve") {
82
+ curves.push(extractNurbsCurveGeometry(node, id));
83
+ }
84
+ else if (subType === "Line") {
85
+ curves.push(extractLineGeometry(node, id));
34
86
  }
35
87
  }
36
88
  }
37
89
  // Extract skeleton/skinning data
38
- const skins = extractSkins(objectMap);
90
+ const skins = extractSkins(objectMap, propertyTemplates);
39
91
  const rigs = resolveRigs(objectMap, skins);
40
92
  // Extract blend shape data
41
93
  const blendShapes = extractBlendShapes(objectMap);
42
94
  // Extract animation data
43
- const animations = extractAnimations(objectMap);
95
+ const animations = extractAnimations(objectMap, doc, { rebaseKeyframes: options.rebaseKeyframes ?? true });
44
96
  // Extract cameras and lights from NodeAttribute objects
45
97
  const cameras = extractCameras(objectMap, propertyTemplates);
46
98
  const lights = extractLights(objectMap, propertyTemplates);
47
99
  const diagnostics = extractSceneDiagnostics(objectMap);
100
+ const constraints = extractConstraints(objectMap);
48
101
  // Build model hierarchy
49
- const rootModels = buildModelHierarchy(objectMap, geometries, materials, propertyTemplates);
102
+ const rootModels = buildModelHierarchy(objectMap, geometries, curves, materials, propertyTemplates);
103
+ const { displayLayers, selectionSets } = extractCollections(objectMap, rootModels);
50
104
  return {
51
105
  rootModels,
106
+ displayLayers,
107
+ selectionSets,
108
+ constraints,
52
109
  geometries,
110
+ curves,
53
111
  materials,
54
112
  skins,
55
113
  rigs,
@@ -61,12 +119,115 @@ export function interpretFBX(doc) {
61
119
  ...globalSettings,
62
120
  };
63
121
  }
122
+ // ── Legacy textures ────────────────────────────────────────────────────────────
123
+ /**
124
+ * Pre-7000 files connect textures to the Model and select them per polygon through the mesh's LayerElementTexture
125
+ * (TextureId indexes the model's textures), instead of connecting textures to material properties. Assign those
126
+ * textures to the diffuse slot of the material used by the same polygons.
127
+ */
128
+ function attachLegacyLayerTextures(objectMap, materials, templates) {
129
+ const materialById = new Map(materials.map((m) => [m.id, m]));
130
+ const textureTemplate = getPropertyTemplate(templates, "Texture", "FbxFileTexture") ?? getPropertyTemplate(templates, "Texture");
131
+ for (const [modelId, modelNode] of Array.from(objectMap.objects)) {
132
+ if (modelNode.name !== "Model") {
133
+ continue;
134
+ }
135
+ const textureChildren = getChildren(objectMap, modelId, "Texture");
136
+ if (textureChildren.length === 0) {
137
+ continue;
138
+ }
139
+ const modelMaterials = getChildren(objectMap, modelId, "Material")
140
+ .filter((c) => !c.propertyName)
141
+ .map((c) => materialById.get(c.id))
142
+ .filter((m) => m !== undefined);
143
+ if (modelMaterials.length === 0) {
144
+ continue;
145
+ }
146
+ const geometryNode = getChildren(objectMap, modelId, "Geometry")[0]?.node ?? modelNode;
147
+ const materialLayer = findChildByName(geometryNode, "LayerElementMaterial");
148
+ const materialIds = materialLayer ? getNodeArray(findChildByName(materialLayer, "Materials")) : null;
149
+ // Each texture kind has its own layer element: LayerElementTexture (diffuse), LayerElementEmissiveTextures,
150
+ // LayerElementSpecularTextures, LayerElementBumpTextures, ...
151
+ const slotForElement = (elementName) => {
152
+ const kind = elementName.replace(/^LayerElement/, "").replace(/Textures?$/, "");
153
+ switch (kind) {
154
+ case "":
155
+ case "Diffuse":
156
+ return { prop: "DiffuseColor", fbx: "diffuseColor", pbr: "baseColor" };
157
+ case "Emissive":
158
+ return { prop: "EmissiveColor", fbx: "emissionColor", pbr: "emissionColor" };
159
+ case "Specular":
160
+ return { prop: "SpecularColor", fbx: "specularColor", pbr: "specularColor" };
161
+ case "Ambient":
162
+ return { prop: "AmbientColor", fbx: "ambientColor", pbr: "ambientOcclusion" };
163
+ case "Bump":
164
+ case "NormalMap":
165
+ return { prop: "NormalMap", fbx: "normalMap", pbr: "normalMap" };
166
+ case "Transparent":
167
+ return { prop: "TransparentColor", fbx: "transparencyColor", pbr: "opacity" };
168
+ case "Reflection":
169
+ return { prop: "ReflectionColor", fbx: "reflectionColor", pbr: "specularColor" };
170
+ case "Shininess":
171
+ return { prop: "ShininessExponent", fbx: "specularExponent", pbr: "roughness" };
172
+ case "Displacement":
173
+ return { prop: "DisplacementColor", fbx: "displacement", pbr: "displacementMap" };
174
+ default:
175
+ return null;
176
+ }
177
+ };
178
+ const layerElements = geometryNode.children.filter((c) => /^LayerElement\w*Textures?$/.test(c.name));
179
+ const textureRefs = textureChildren.map(({ id, node }) => extractTextureRef(id, node, undefined, objectMap, textureTemplate));
180
+ const assign = (materialIndex, textureIndex, slot) => {
181
+ var _a, _b, _c, _d;
182
+ const material = modelMaterials[materialIndex];
183
+ const source = textureRefs[textureIndex];
184
+ if (!material || !source || material.textures.some((t) => t.propertyName === slot.prop)) {
185
+ return;
186
+ }
187
+ const ref = { ...source, propertyName: slot.prop };
188
+ material.textures.push(ref);
189
+ const fbxMap = ((_a = material.model.fbx)[_b = slot.fbx] ?? (_a[_b] = { valueComponents: 0, textureEnabled: false }));
190
+ fbxMap.texture = ref;
191
+ fbxMap.textureEnabled = true;
192
+ const pbrMap = ((_c = material.model.pbr)[_d = slot.pbr] ?? (_c[_d] = { valueComponents: 0, textureEnabled: false }));
193
+ pbrMap.texture = ref;
194
+ pbrMap.textureEnabled = true;
195
+ };
196
+ if (layerElements.length === 0) {
197
+ // No layer element: a single texture on a single-material model is the diffuse map.
198
+ if (modelMaterials.length === 1 && textureRefs.length === 1) {
199
+ assign(0, 0, slotForElement("LayerElementTexture"));
200
+ }
201
+ continue;
202
+ }
203
+ for (const element of layerElements) {
204
+ const slot = slotForElement(element.name);
205
+ const textureIds = getNodeArray(findChildByName(element, "TextureId"));
206
+ if (!slot || !textureIds || textureIds.length === 0) {
207
+ continue;
208
+ }
209
+ const polygonCount = Math.max(textureIds.length, materialIds?.length ?? 0);
210
+ for (let poly = 0; poly < polygonCount; poly++) {
211
+ const texIndex = textureIds[Math.min(poly, textureIds.length - 1)];
212
+ const matIndex = materialIds && materialIds.length > 0 ? materialIds[Math.min(poly, materialIds.length - 1)] : 0;
213
+ if (texIndex >= 0) {
214
+ assign(matIndex, texIndex, slot);
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
64
220
  // ── Model Hierarchy ────────────────────────────────────────────────────────────
65
- function buildModelHierarchy(objectMap, geometries, materials, propertyTemplates) {
221
+ const MAX_MODEL_DEPTH = 512;
222
+ function buildModelHierarchy(objectMap, geometries, curves, materials, propertyTemplates) {
66
223
  const geometryMap = new Map();
67
224
  for (const g of geometries) {
68
225
  geometryMap.set(g.id, g);
69
226
  }
227
+ const curveMap = new Map();
228
+ for (const c of curves) {
229
+ curveMap.set(c.id, c);
230
+ }
70
231
  const materialMap = new Map();
71
232
  for (const m of materials) {
72
233
  materialMap.set(m.id, m);
@@ -74,20 +235,44 @@ function buildModelHierarchy(objectMap, geometries, materials, propertyTemplates
74
235
  // Find root models (those connected to ID 0, which is the scene root)
75
236
  const rootChildren = objectMap.childrenOf.get(0) ?? [];
76
237
  const rootModels = [];
238
+ const rootIds = new Set();
239
+ const buildStack = new Set();
77
240
  for (const { id } of rootChildren) {
78
241
  const node = objectMap.objects.get(id);
79
- if (node && node.name === "Model") {
80
- rootModels.push(buildModel(id, node, objectMap, geometryMap, materialMap, propertyTemplates));
242
+ if (node && node.name === "Model" && !rootIds.has(id)) {
243
+ rootIds.add(id);
244
+ rootModels.push(buildModel(id, node, objectMap, geometryMap, curveMap, materialMap, propertyTemplates, buildStack));
245
+ }
246
+ }
247
+ // Models without any Model (or scene root) parent connection also live at the root, as in the FBX SDK.
248
+ // Typical examples are the default "Producer" cameras and the camera switcher written by Maya, MotionBuilder
249
+ // and Blender in 6.x files, which are never connected.
250
+ const hasModelParent = new Set();
251
+ for (const conn of objectMap.connections) {
252
+ if (conn.type !== "OO") {
253
+ continue;
254
+ }
255
+ const parentNode = objectMap.objects.get(conn.parentId);
256
+ if (conn.parentId === 0 || parentNode?.name === "Model") {
257
+ hasModelParent.add(conn.childId);
258
+ }
259
+ }
260
+ for (const [id, node] of Array.from(objectMap.objects)) {
261
+ if (node.name === "Model" && !hasModelParent.has(id) && !rootIds.has(id)) {
262
+ rootIds.add(id);
263
+ rootModels.push(buildModel(id, node, objectMap, geometryMap, curveMap, materialMap, propertyTemplates, buildStack));
81
264
  }
82
265
  }
83
266
  return rootModels;
84
267
  }
85
- function buildModel(modelId, modelNode, objectMap, geometryMap, materialMap, propertyTemplates) {
268
+ function buildModel(modelId, modelNode, objectMap, geometryMap, curveMap, materialMap, propertyTemplates, buildStack) {
86
269
  const name = cleanFBXName(getPropertyValue(modelNode, 1) ?? "Model");
270
+ buildStack.add(modelId);
87
271
  const subType = getPropertyValue(modelNode, 2) ?? "Null";
88
272
  // Find attached geometry
89
273
  const geomChildren = getChildren(objectMap, modelId, "Geometry");
90
- const geometry = geomChildren.length > 0 ? geometryMap.get(geomChildren[0].id) : undefined;
274
+ const geometry = geomChildren.map((child) => geometryMap.get(child.id)).find((g) => g !== undefined);
275
+ const curve = geomChildren.map((child) => curveMap.get(child.id)).find((c) => c !== undefined);
91
276
  // Find attached materials
92
277
  const matChildren = getChildren(objectMap, modelId, "Material");
93
278
  const modelMaterials = [];
@@ -100,16 +285,52 @@ function buildModel(modelId, modelNode, objectMap, geometryMap, materialMap, pro
100
285
  // Extract transform
101
286
  const transform = extractTransform(modelNode, getPropertyTemplate(propertyTemplates, "Model", "FbxNode") ?? getPropertyTemplate(propertyTemplates, "Model"));
102
287
  // Recursively build child models
103
- const childModelNodes = getChildren(objectMap, modelId, "Model");
288
+ // Only object-object connections express parenting; OP connections (look-at targets, constraint sources) do not.
289
+ const childModelNodes = getChildren(objectMap, modelId, "Model").filter((child) => !child.propertyName);
104
290
  const children = [];
291
+ const cycleDiagnostics = [];
105
292
  for (const { id, node } of childModelNodes) {
106
- children.push(buildModel(id, node, objectMap, geometryMap, materialMap, propertyTemplates));
293
+ if (buildStack.size > MAX_MODEL_DEPTH) {
294
+ cycleDiagnostics.push(`Model hierarchy deeper than ${MAX_MODEL_DEPTH} levels; deeper children were skipped.`);
295
+ break;
296
+ }
297
+ if (buildStack.has(id)) {
298
+ // Malformed files (duplicate ids, recursive connections) can make a model its own ancestor.
299
+ cycleDiagnostics.push(`Model connection cycle detected at '${cleanFBXName(getPropertyValue(node, 1) ?? "")}'; the recursive child was skipped.`);
300
+ continue;
301
+ }
302
+ children.push(buildModel(id, node, objectMap, geometryMap, curveMap, materialMap, propertyTemplates, buildStack));
107
303
  }
304
+ buildStack.delete(modelId);
108
305
  // Extract culling
109
306
  const cullingNode = modelNode.children.find((c) => c.name === "Culling");
110
307
  const cullingOff = cullingNode ? getPropertyValue(cullingNode, 0) === "CullingOff" : false;
111
308
  // Extract user-defined custom properties
112
309
  const customProperties = extractCustomProperties(modelNode);
310
+ // LOD groups carry their thresholds on the LodGroup node attribute
311
+ let lodGroup;
312
+ if (subType === "LodGroup") {
313
+ const attribute = getChildren(objectMap, modelId, "NodeAttribute").find((a) => getPropertyValue(a.node, 2) === "LodGroup");
314
+ const entries = attribute ? getPropertyEntries(attribute.node) : [];
315
+ const thresholds = [];
316
+ const displayLevels = [];
317
+ for (let i = 0;; i++) {
318
+ const entry = entries.find((e) => e.name === `Thresholds|Level${i}`);
319
+ if (!entry) {
320
+ break;
321
+ }
322
+ thresholds.push(typeof entry.values[0] === "number" ? entry.values[0] : 0);
323
+ }
324
+ for (let i = 0;; i++) {
325
+ const entry = entries.find((e) => e.name === `DisplayLevels|Level${i}`);
326
+ if (!entry) {
327
+ break;
328
+ }
329
+ displayLevels.push(typeof entry.values[0] === "number" ? entry.values[0] : 0);
330
+ }
331
+ const relativeEntry = entries.find((e) => e.name === "ThresholdsUsedAsPercentage");
332
+ lodGroup = { thresholds, relative: !!relativeEntry && relativeEntry.values[0] !== 0 && relativeEntry.values[0] !== false, displayLevels };
333
+ }
113
334
  return {
114
335
  id: modelId,
115
336
  name,
@@ -119,47 +340,87 @@ function buildModel(modelId, modelNode, objectMap, geometryMap, materialMap, pro
119
340
  children,
120
341
  cullingOff,
121
342
  customProperties,
343
+ lodGroup,
344
+ curve,
122
345
  ...transform,
346
+ diagnostics: [...transform.diagnostics, ...cycleDiagnostics],
123
347
  };
124
348
  }
125
- function extractTransform(modelNode, template) {
126
- const translation = resolveVector3Property(modelNode, template, "Lcl Translation", [0, 0, 0]);
127
- const rotation = resolveVector3Property(modelNode, template, "Lcl Rotation", [0, 0, 0]);
128
- const scale = resolveVector3Property(modelNode, template, "Lcl Scaling", [1, 1, 1]);
129
- const preRotation = resolveVector3Property(modelNode, template, "PreRotation", [0, 0, 0]);
130
- const postRotation = resolveVector3Property(modelNode, template, "PostRotation", [0, 0, 0]);
131
- const rotationPivot = resolveVector3Property(modelNode, template, "RotationPivot", [0, 0, 0]);
132
- const scalingPivot = resolveVector3Property(modelNode, template, "ScalingPivot", [0, 0, 0]);
133
- const rotationOffset = resolveVector3Property(modelNode, template, "RotationOffset", [0, 0, 0]);
134
- const scalingOffset = resolveVector3Property(modelNode, template, "ScalingOffset", [0, 0, 0]);
135
- const geometricTranslation = resolveVector3Property(modelNode, template, "GeometricTranslation", [0, 0, 0]);
136
- const geometricRotation = resolveVector3Property(modelNode, template, "GeometricRotation", [0, 0, 0]);
137
- const geometricScaling = resolveVector3Property(modelNode, template, "GeometricScaling", [1, 1, 1]);
138
- const rotationOrder = resolveNumberProperty(modelNode, template, "RotationOrder", 0);
139
- const inheritType = resolveNumberProperty(modelNode, template, "InheritType", 1);
140
- const diagnostics = inheritType !== 1 && inheritType !== 2
141
- ? [
142
- `InheritType ${inheritType} is parsed and preserved; runtime parent-scale inheritance remains gated to avoid changing existing visual behavior without a fixture-specific baseline.`,
143
- ]
144
- : [];
145
- return {
146
- translation,
147
- rotation,
148
- scale,
149
- preRotation,
150
- postRotation,
151
- rotationPivot,
152
- scalingPivot,
153
- rotationOffset,
154
- scalingOffset,
155
- geometricTranslation,
156
- geometricRotation,
157
- geometricScaling,
158
- rotationOrder,
159
- inheritType,
160
- diagnostics,
349
+ /** Display layers and selection sets: which models belong to which collection. */
350
+ function extractCollections(objectMap, rootModels) {
351
+ const modelById = new Map();
352
+ const walk = (models) => {
353
+ for (const m of models) {
354
+ modelById.set(m.id, m);
355
+ walk(m.children);
356
+ }
161
357
  };
358
+ walk(rootModels);
359
+ const displayLayers = [];
360
+ const selectionSets = [];
361
+ for (const [id, node] of Array.from(objectMap.objects)) {
362
+ if (node.name === "CollectionExclusive") {
363
+ const entries = getPropertyEntries(node);
364
+ const num = (name, fallback) => {
365
+ const v = entries.find((e) => e.name === name)?.values[0];
366
+ return typeof v === "number" ? v : typeof v === "boolean" ? (v ? 1 : 0) : fallback;
367
+ };
368
+ const colorValues = entries.find((e) => e.name === "Color")?.values;
369
+ const color = colorValues && typeof colorValues[0] === "number" && typeof colorValues[1] === "number" && typeof colorValues[2] === "number"
370
+ ? [colorValues[0], colorValues[1], colorValues[2]]
371
+ : [0.8, 0.8, 0.8];
372
+ const layer = {
373
+ id,
374
+ name: cleanFBXName(getPropertyValue(node, 1) ?? "DisplayLayer"),
375
+ show: num("Show", 1) !== 0,
376
+ freeze: num("Freeze", 0) !== 0,
377
+ color,
378
+ modelIds: getChildren(objectMap, id, "Model").map((c) => c.id),
379
+ };
380
+ displayLayers.push(layer);
381
+ for (const modelId of layer.modelIds) {
382
+ const model = modelById.get(modelId);
383
+ if (model) {
384
+ model.displayLayer = layer;
385
+ }
386
+ }
387
+ }
388
+ else if (node.name === "SelectionSet" || (node.name === "Collection" && getPropertyValue(node, 2) === "SelectionSet")) {
389
+ const members = [];
390
+ for (const child of getChildren(objectMap, id)) {
391
+ if (child.node.name === "Model") {
392
+ members.push({ modelId: child.id, includeNode: true });
393
+ }
394
+ else if (child.node.name === "SelectionNode") {
395
+ const indices = (name) => {
396
+ const arrayNode = findChildByName(child.node, name);
397
+ if (!arrayNode) {
398
+ return undefined;
399
+ }
400
+ return Array.from(getNodeArray(arrayNode));
401
+ };
402
+ const inSet = findChildByName(child.node, "IsTheNodeInSet")?.properties[0]?.value;
403
+ for (const target of getChildren(objectMap, child.id, "Model")) {
404
+ members.push({
405
+ modelId: target.id,
406
+ includeNode: inSet === undefined || inSet === 1 || inSet === true,
407
+ vertices: indices("VertexIndexArray"),
408
+ edges: indices("EdgeIndexArray"),
409
+ faces: indices("PolygonIndexArray"),
410
+ });
411
+ }
412
+ }
413
+ }
414
+ selectionSets.push({ id, name: cleanFBXName(getPropertyValue(node, 1) ?? "SelectionSet"), members });
415
+ }
416
+ }
417
+ return { displayLayers, selectionSets };
418
+ }
419
+ function extractTransform(modelNode, template) {
420
+ return extractNodeTransform(modelNode, template);
162
421
  }
422
+ // Frame rates per FBX TimeMode enum value (eDefaultMode, eFrames120, ... eFrames59dot94).
423
+ const TIME_MODE_FPS = [30, 120, 100, 60, 50, 48, 30, 30, 29.97, 29.97, 25, 24, 1000, 23.976, 24, 96, 72, 59.94];
163
424
  function extractGlobalSettings(doc) {
164
425
  const defaults = {
165
426
  upAxis: 1,
@@ -169,21 +430,16 @@ function extractGlobalSettings(doc) {
169
430
  coordAxis: 0,
170
431
  coordAxisSign: 1,
171
432
  unitScaleFactor: 1,
433
+ frameRate: 30,
172
434
  };
173
435
  const gsNode = findDocumentNode(doc, "GlobalSettings");
174
436
  if (!gsNode) {
175
437
  return defaults;
176
438
  }
177
- const props70 = gsNode.children.find((c) => c.name === "Properties70");
178
- if (!props70) {
179
- return defaults;
180
- }
181
- for (const p of props70.children) {
182
- if (p.name !== "P") {
183
- continue;
184
- }
185
- const propName = getPropertyValue(p, 0);
186
- const value = toNumber(p.properties[4]?.value);
439
+ let timeMode = 0;
440
+ let customFrameRate = 24;
441
+ for (const { name: propName, values } of getPropertyEntries(gsNode)) {
442
+ const value = toNumber(values[0]);
187
443
  if (propName && value !== undefined) {
188
444
  switch (propName) {
189
445
  case "UpAxis":
@@ -207,9 +463,21 @@ function extractGlobalSettings(doc) {
207
463
  case "UnitScaleFactor":
208
464
  defaults.unitScaleFactor = value;
209
465
  break;
466
+ case "TimeMode":
467
+ timeMode = value;
468
+ break;
469
+ case "CustomFrameRate":
470
+ customFrameRate = value;
471
+ break;
210
472
  }
211
473
  }
212
474
  }
475
+ if (timeMode === 14) {
476
+ defaults.frameRate = customFrameRate > 0 ? customFrameRate : 24;
477
+ }
478
+ else {
479
+ defaults.frameRate = TIME_MODE_FPS[timeMode] ?? 30;
480
+ }
213
481
  return defaults;
214
482
  }
215
483
  // ── Cameras & Lights ──────────────────────────────────────────────────────────
@@ -235,38 +503,17 @@ const SYSTEM_PROPERTIES = new Set([
235
503
  "lockInfluenceWeights",
236
504
  ]);
237
505
  function extractCustomProperties(modelNode) {
238
- const props70 = findChildByName(modelNode, "Properties70");
239
- if (!props70) {
506
+ // Properties flagged "U" are user-defined. Some writers (MotionBuilder) flag their own template properties
507
+ // as user properties too, so the well-known node properties are filtered out again.
508
+ const flagged = extractUserProperties(modelNode);
509
+ if (!flagged) {
240
510
  return undefined;
241
511
  }
242
- const custom = {};
243
512
  let hasAny = false;
244
- for (const p of props70.children) {
245
- if (p.name !== "P") {
246
- continue;
247
- }
248
- const propName = getPropertyValue(p, 0);
249
- if (!propName || SYSTEM_PROPERTIES.has(propName)) {
250
- continue;
251
- }
252
- // Accept user-defined properties (type starts with something other than standard types)
253
- // Standard FBX types: "KString", "Number", "double", "int", "bool", "Lcl"...
254
- // User properties often have types like "KString", but are in the UDP (User Defined Properties) section
255
- // Heuristic: if not in SYSTEM_PROPERTIES set, it's user-defined
256
- const val = p.properties[4]?.value;
257
- if (val === undefined) {
258
- continue;
259
- }
260
- if (typeof val === "string") {
261
- custom[propName] = val;
262
- hasAny = true;
263
- }
264
- else if (typeof val === "number") {
265
- custom[propName] = val;
266
- hasAny = true;
267
- }
268
- else if (typeof val === "boolean") {
269
- custom[propName] = val;
513
+ const custom = {};
514
+ for (const name of Object.keys(flagged)) {
515
+ if (!SYSTEM_PROPERTIES.has(name)) {
516
+ custom[name] = flagged[name];
270
517
  hasAny = true;
271
518
  }
272
519
  }
@@ -329,7 +576,6 @@ function extractCameras(objectMap, templates) {
329
576
  const name = cleanFBXName(getPropertyValue(parentNode, 1) ?? "Camera");
330
577
  const nearPlane = resolveNumberProperty(node, cameraTemplate, "NearPlane", 0.1);
331
578
  const farPlane = resolveNumberProperty(node, cameraTemplate, "FarPlane", 10000);
332
- const aspectRatio = resolveCameraAspectRatio(node, cameraTemplate);
333
579
  const projectionType = resolveNumberProperty(node, cameraTemplate, "CameraProjectionType", 0) === 1 || resolveNumberProperty(node, cameraTemplate, "ProjectionType", 0) === 1
334
580
  ? "orthographic"
335
581
  : "perspective";
@@ -338,7 +584,9 @@ function extractCameras(objectMap, templates) {
338
584
  const filmHeight = toNumber(resolvePropertyValue(node, cameraTemplate, "FilmHeight")) ?? toNumber(resolvePropertyValue(node, cameraTemplate, "ApertureHeight"));
339
585
  const orthoZoom = toNumber(resolvePropertyValue(node, cameraTemplate, "OrthoZoom"));
340
586
  const roll = toNumber(resolvePropertyValue(node, cameraTemplate, "Roll"));
341
- const fieldOfView = resolveCameraFieldOfView(node, cameraTemplate, aspectRatio, focalLength, filmHeight);
587
+ const resolved = resolveCameraParameters(node, cameraTemplate);
588
+ const aspectRatio = resolved.aspectRatio;
589
+ const fieldOfView = resolved.fovDegY;
342
590
  const diagnostics = [];
343
591
  if (projectionType === "orthographic" && orthoZoom === undefined) {
344
592
  diagnostics.push("Orthographic camera has no OrthoZoom; runtime orthographic bounds use a fallback.");
@@ -348,8 +596,14 @@ function extractCameras(objectMap, templates) {
348
596
  }
349
597
  cameras.push({
350
598
  modelId: parent.id,
599
+ attributeId: id,
600
+ userProperties: extractUserProperties(node),
351
601
  name,
352
602
  fieldOfView,
603
+ fieldOfViewX: resolved.fovDegX,
604
+ apertureMode: resolved.apertureMode,
605
+ apertureSizeInch: resolved.apertureSizeInch,
606
+ orthographicSize: resolved.orthographicSize,
353
607
  nearPlane,
354
608
  farPlane,
355
609
  aspectRatio,
@@ -408,6 +662,13 @@ function extractLights(objectMap, templates) {
408
662
  }
409
663
  lights.push({
410
664
  modelId: parent.id,
665
+ attributeId: id,
666
+ userProperties: extractUserProperties(node),
667
+ nearAttenuationStart: toNumber(resolvePropertyValue(node, lightTemplate, "NearAttenuationStart")),
668
+ nearAttenuationEnd: toNumber(resolvePropertyValue(node, lightTemplate, "NearAttenuationEnd")),
669
+ farAttenuationStart: toNumber(resolvePropertyValue(node, lightTemplate, "FarAttenuationStart")),
670
+ farAttenuationEnd: toNumber(resolvePropertyValue(node, lightTemplate, "FarAttenuationEnd")),
671
+ areaLightShape: toNumber(resolvePropertyValue(node, lightTemplate, "AreaLightShape")),
411
672
  name,
412
673
  lightType,
413
674
  color,
@@ -442,34 +703,131 @@ function toBoolean(value) {
442
703
  }
443
704
  return undefined;
444
705
  }
445
- function resolveCameraAspectRatio(node, template) {
446
- const filmAspectRatio = toNumber(resolvePropertyValue(node, template, "FilmAspectRatio"));
447
- if (filmAspectRatio !== undefined && filmAspectRatio > 0) {
448
- return filmAspectRatio;
706
+ /** Film back sizes in thousandths of an inch per FBX ApertureFormat enum value. */
707
+ const APERTURE_FORMATS = [
708
+ [1000, 1000],
709
+ [404, 295],
710
+ [493, 292],
711
+ [864, 630],
712
+ [816, 612],
713
+ [980, 735],
714
+ [825, 446],
715
+ [864, 732],
716
+ [2066, 906],
717
+ [1485, 991],
718
+ [2080, 1480],
719
+ [2772, 2072],
720
+ ];
721
+ /**
722
+ * Resolves the camera's projection parameters the way the FBX SDK does: aspect ratio from AspectWidth/Height and
723
+ * the aspect mode, aperture (film gate) from the film size, squeeze ratio and gate fit, and the horizontal and
724
+ * vertical field of view from the aperture mode (horizontal, vertical, both, or focal length).
725
+ */
726
+ function resolveCameraParameters(node, template) {
727
+ const num = (name, fallback) => toNumber(resolvePropertyValue(node, template, name)) ?? fallback;
728
+ const aspectMode = num("AspectRatioMode", 0);
729
+ const apertureMode = num("ApertureMode", 2);
730
+ const apertureFormat = Math.max(0, Math.min(APERTURE_FORMATS.length - 1, Math.round(num("ApertureFormat", 0))));
731
+ const gateFit = num("GateFit", 0);
732
+ let aspectX = num("AspectW", 0);
733
+ let aspectY = num("AspectH", 0);
734
+ aspectX = num("AspectWidth", aspectX);
735
+ aspectY = num("AspectHeight", aspectY);
736
+ const fov = num("FieldOfView", 0);
737
+ const fovX = num("FieldOfViewX", 0);
738
+ const fovY = num("FieldOfViewY", 0);
739
+ const focalLength = num("FocalLength", 0);
740
+ const orthoExtent = 30 * num("OrthoZoom", 1);
741
+ const format = APERTURE_FORMATS[apertureFormat];
742
+ let filmX = num("FilmWidth", format[0] * 0.001);
743
+ let filmY = num("FilmHeight", format[1] * 0.001);
744
+ const squeeze = num("FilmSqueezeRatio", apertureFormat === 7 ? 2 : 1);
745
+ if (aspectX <= 0 && aspectY <= 0) {
746
+ aspectX = filmX > 0 ? filmX : 1;
747
+ aspectY = filmY > 0 ? filmY : 1;
449
748
  }
450
- const aspectWidth = toNumber(resolvePropertyValue(node, template, "AspectWidth"));
451
- const aspectHeight = toNumber(resolvePropertyValue(node, template, "AspectHeight"));
452
- if (aspectWidth !== undefined && aspectHeight !== undefined && aspectWidth > 0 && aspectHeight > 0) {
453
- return aspectWidth / aspectHeight;
749
+ else if (aspectX <= 0) {
750
+ aspectX = filmX > 0 && filmY > 0 ? (aspectY / filmY) * filmX : aspectY;
454
751
  }
455
- return 0;
456
- }
457
- function resolveCameraFieldOfView(node, template, aspectRatio, focalLength, filmHeight) {
458
- const verticalFov = toNumber(resolvePropertyValue(node, template, "FieldOfViewY")) ?? toNumber(resolvePropertyValue(node, template, "FieldOfView"));
459
- if (verticalFov !== undefined) {
460
- return verticalFov;
752
+ else if (aspectY <= 0) {
753
+ aspectY = filmX > 0 && filmY > 0 ? (aspectX / filmX) * filmY : aspectX;
461
754
  }
462
- const horizontalFov = toNumber(resolvePropertyValue(node, template, "FieldOfViewX"));
463
- if (horizontalFov !== undefined) {
464
- if (aspectRatio > 0) {
465
- return radiansToDegrees(2 * Math.atan(Math.tan(degreesToRadians(horizontalFov) / 2) / aspectRatio));
466
- }
467
- return horizontalFov;
755
+ filmY *= squeeze;
756
+ let resX = aspectX;
757
+ let resY = aspectY;
758
+ switch (aspectMode) {
759
+ case 3: // fixed width
760
+ resX = aspectX;
761
+ resY = aspectX * aspectY;
762
+ break;
763
+ case 4: // fixed height
764
+ resX = aspectY * aspectX;
765
+ resY = aspectY;
766
+ break;
767
+ default:
768
+ break;
769
+ }
770
+ const aspectRatio = resY !== 0 ? resX / resY : 1;
771
+ const filmRatio = filmY !== 0 ? filmX / filmY : 1;
772
+ let effectiveFit = gateFit;
773
+ if (effectiveFit === 3) {
774
+ effectiveFit = aspectRatio > filmRatio ? 2 : 1;
775
+ }
776
+ else if (effectiveFit === 4) {
777
+ effectiveFit = aspectRatio < filmRatio ? 2 : 1;
778
+ }
779
+ let apertureX = filmX;
780
+ let apertureY = filmY;
781
+ let orthoX = orthoExtent;
782
+ let orthoY = orthoExtent;
783
+ switch (effectiveFit) {
784
+ case 1: // vertical
785
+ apertureX = filmY * aspectRatio;
786
+ apertureY = filmY;
787
+ orthoX = orthoExtent * aspectRatio;
788
+ break;
789
+ case 2: // horizontal
790
+ apertureX = filmX;
791
+ apertureY = filmX / aspectRatio;
792
+ orthoY = orthoExtent / aspectRatio;
793
+ break;
794
+ default:
795
+ break;
468
796
  }
469
- if (focalLength !== undefined && focalLength > 0 && filmHeight !== undefined && filmHeight > 0) {
470
- return radiansToDegrees(2 * Math.atan((filmHeight * 25.4) / (2 * focalLength)));
797
+ filmX = apertureX;
798
+ void filmX;
799
+ const d2r = Math.PI / 180;
800
+ const r2d = 180 / Math.PI;
801
+ let tanX;
802
+ let tanY;
803
+ switch (apertureMode) {
804
+ case 0:
805
+ tanX = Math.tan(fovX * d2r * 0.5);
806
+ tanY = Math.tan(fovY * d2r * 0.5);
807
+ break;
808
+ case 1:
809
+ tanX = Math.tan(fov * d2r * 0.5);
810
+ tanY = tanX / aspectRatio;
811
+ break;
812
+ case 3: {
813
+ const focalInch = focalLength / 25.4;
814
+ tanX = focalInch > 0 ? (apertureX / focalInch) * 0.5 : Math.tan(fov * d2r * 0.5) * aspectRatio;
815
+ tanY = focalInch > 0 ? (apertureY / focalInch) * 0.5 : Math.tan(fov * d2r * 0.5);
816
+ break;
817
+ }
818
+ default:
819
+ tanY = Math.tan(fov * d2r * 0.5);
820
+ tanX = tanY * aspectRatio;
821
+ break;
471
822
  }
472
- return 45;
823
+ return {
824
+ fovDegX: Math.atan(tanX) * r2d * 2,
825
+ fovDegY: Math.atan(tanY) * r2d * 2,
826
+ aspectRatio,
827
+ apertureMode,
828
+ apertureSizeInch: [apertureX, apertureY],
829
+ orthographicSize: [orthoX, orthoY],
830
+ };
473
831
  }
474
832
  function collectUnknownLocalProperties(node, known) {
475
833
  const unknown = new Set();
@@ -487,10 +845,4 @@ function collectUnknownLocalProperties(node, known) {
487
845
  }
488
846
  return Array.from(unknown).sort();
489
847
  }
490
- function degreesToRadians(degrees) {
491
- return (degrees * Math.PI) / 180;
492
- }
493
- function radiansToDegrees(radians) {
494
- return (radians * 180) / Math.PI;
495
- }
496
848
  //# sourceMappingURL=fbxInterpreter.js.map