Resources Numerical accuracy Calculation policy

FBX import: axes, units and transforms

Turns an FBX file's axes and unit into Z up and meters, and builds each object's matrix from its translation, pivots, rotations and scaling.

How the precision is kept

The file's unit (centimeters per unit) is multiplied by 0.01 to get meters and put, with the axis signs, into one matrix A; points become A × p and node matrices A × M × A⁻¹. Object matrices are products of eleven 4×4 matrices in 64-bit numbers. Rotations are Euler angles in degrees, converted with π ÷ 180.

Open item. Compare the axis determinant with the unit factor cubed instead of the fixed 1e-12, so a small file unit does not switch off the axis conversion.

Calculations under this policy

7 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.

io/fbx-import.js ModelCraft 0.8.1 64-bit 7 calculations Reviewed

Tests tools/modelcraft-test/fbx.mjs

Axes and unit

64-bit

The matrix that takes the file's coordinates to Z up and meters.

k = UnitScaleFactor × 0.01; columns = ± k along the model axis each file axis maps to

k is the file's unit factor times 0.01, which is not exactly representable in binary. The fallback test |det| < 1e-12 is absolute while the determinant equals ± k³, so for k below about 1e-4 a valid set of axes is replaced by a plain scale.

  • io/fbx-import.js
  • lines 146 to 157
  • ModelCraft 0.8.1
/** The file's axes and unit → ModelCraft (Z up, metres): up → +Z, front → −Y, coord → +X. */
export function axisMatrix(s) {
  const e = (i, sign) => { const v = [0, 0, 0]; if (i >= 0 && i <= 2) v[i] = sign < 0 ? -1 : 1; return v; };
  // rows of the map: model X = coord axis, model Y = −front axis, model Z = up axis (each a unit vector in file space)
  const X = e(s.coord, s.coordSign); const Y = e(s.front, s.frontSign).map((v) => -v); const Z = e(s.up, s.upSign);
  const k = (s.unit > 0 && Number.isFinite(s.unit) ? s.unit : 1) * 0.01;
  // column-major: column j = the model coordinates of file axis j
  const m = ident();
  for (let j = 0; j < 3; j++) { m[j * 4] = X[j] * k; m[j * 4 + 1] = Y[j] * k; m[j * 4 + 2] = Z[j] * k; }
  if (Math.abs(det(m)) < 1e-12) return S([k, k, k]);   // contradictory axes: just the unit
  return m;
}

Node matrices into the model's axes

64-bit

Carries a node's matrix from the file's axes and unit into the model's.

M′ = A × M × A⁻¹

A⁻¹ comes from invert in vec.js. The unit factor in A meets its reciprocal in A⁻¹ on the rotation part, where their 64-bit product can differ from 1 in the last bit; the translation is scaled by the unit factor.

  • io/fbx-import.js
  • lines 165 to 167
  • ModelCraft 0.8.1
const A = axisMatrix(scene.settings);
const AI = invert(A);
const conj = (m) => mmul(A, mmul(m, AI));

Euler angles to a matrix

64-bit

The rotation matrix of three angles in degrees in the file's rotation order.

XYZ order: R = Rz × Ry × Rx, angles × π ÷ 180

Three single-axis matrices multiplied in 64-bit. Angles such as 90° give a cosine of about 6e-17 instead of 0. An unknown order number falls back to XYZ.

  • io/fbx-import.js
  • lines 122 to 127
  • ModelCraft 0.8.1
/** Euler angles (degrees) in a RotationOrder: "XYZ" turns about X first, then Y, then Z. */
function euler(v, order = 0) {
  const [x, y, z] = [Rx(v[0]), Ry(v[1]), Rz(v[2])];
  const seq = [[z, y, x], [y, z, x], [x, z, y], [z, x, y], [y, x, z], [x, y, z]][order] || [z, y, x];
  return mmul(seq[0], mmul(seq[1], seq[2]));
}

An object's own matrix

64-bit

Builds a model's local matrix from its translation, offsets, pivots, pre-, own and post-rotation, and scaling.

M = T × Roff × Rp × Rpre × R × Rpost⁻¹ × Rp⁻¹ × Soff × Sp × S × Sp⁻¹

Eleven 4×4 matrices multiplied left to right in 64-bit; the post-rotation is inverted with the general inverse. Missing or non-finite properties take their defaults.

  • io/fbx-import.js
  • lines 131 to 142
  • ModelCraft 0.8.1
function localMatrix(p) {
  const order = num(p, 'RotationOrder', 0);
  const rp = vec3(p, 'RotationPivot', [0, 0, 0]);
  const sp = vec3(p, 'ScalingPivot', [0, 0, 0]);
  const parts = [
    T(vec3(p, 'Lcl Translation', [0, 0, 0])), T(vec3(p, 'RotationOffset', [0, 0, 0])), T(rp),
    euler(vec3(p, 'PreRotation', [0, 0, 0]), 0), euler(vec3(p, 'Lcl Rotation', [0, 0, 0]), order),
    invert(euler(vec3(p, 'PostRotation', [0, 0, 0]), 0)), T(rp.map((x) => -x)),
    T(vec3(p, 'ScalingOffset', [0, 0, 0])), T(sp), S(vec3(p, 'Lcl Scaling', [1, 1, 1])), T(sp.map((x) => -x)),
  ];
  return parts.reduce((m, q) => mmul(m, q), ident());
}

Mesh points into the model

64-bit

Carries each control point through the axis and unit matrix.

p′ = A × p

Each coordinate is multiplied by ± k and stored in a Float64Array. Faces are turned round when the determinant of A is negative.

  • io/fbx-import.js
  • lines 267 to 269
  • ModelCraft 0.8.1
const pts = new Float64Array(nv * 3);
for (let i = 0; i < nv; i++) { const q = apply(A, [V[i * 3], V[i * 3 + 1], V[i * 3 + 2]]); pts[i * 3] = q[0]; pts[i * 3 + 1] = q[1]; pts[i * 3 + 2] = q[2]; }
const flip = det(A) < 0;

Lamps

64-bit

A lamp's position, direction and brightness from an FBX light.

lumens = Intensity × 8; direction = the node's −Y, made unit

FBX intensity has no unit; the factor 8 is the file's stated convention (100 gives 800 lumens), not a physical conversion. Position and direction go through the 64-bit world matrix.

  • io/fbx-import.js
  • lines 355 to 367
  • ModelCraft 0.8.1
function addLight(model, attr, M, report, skip) {
  const p = attr.props;
  const type = num(p, 'LightType', 0);
  if (type === 1) { skip(t('fbx.note.directional')); return; }
  if (type > 2) { skip(t('fbx.note.area_light')); return; }
  if (!model.addLight) return;
  const c = vec3(p, 'Color', [1, 1, 1]);
  const hex = '#' + c.map((x) => Math.round(Math.min(1, Math.max(0, x)) * 255).toString(16).padStart(2, '0')).join('');
  const dir = norm(applyDir(M, [0, -1, 0]));   // FBX spot lights shine along their own −Y
  model.addLight({ kind: type === 2 ? 'spot' : 'point', p: apply(M, [0, 0, 0]), dir: len(dir) ? dir : [0, 0, -1], color: hex,
    lumens: num(p, 'Intensity', 100) * 8, angle: num(p, 'OuterAngle', num(p, 'Cone angle', 45)), name: attr.name || '' });
  report.lights++;
}

Cameras

64-bit

A saved view's eye, target and field of view from an FBX camera.

target = eye + 10 × forward; parallel view height = OrthoZoom × 10

The target distance of 10 and the factor 10 on OrthoZoom are fixed choices in the code. The field of view is copied in degrees.

  • io/fbx-import.js
  • lines 369 to 379
  • ModelCraft 0.8.1
function addCamera(model, attr, M, report) {
  if (!Array.isArray(model.scenes)) return;
  const p = attr.props;
  const eye = apply(M, [0, 0, 0]);
  const fwd = norm(applyDir(M, [1, 0, 0]));   // FBX cameras look along their own +X
  if (!len(fwd)) return;
  const target = [eye[0] + fwd[0] * 10, eye[1] + fwd[1] * 10, eye[2] + fwd[2] * 10];
  const ortho = num(p, 'CameraProjectionType', 0) === 1;
  const s = cleanScene({ name: attr.name || 'Camera', cam: ortho ? { eye, target, persp: false, height: num(p, 'OrthoZoom', 1) * 10 } : { eye, target, persp: true, fov: num(p, 'FieldOfViewY', num(p, 'FieldOfView', 40)) } });   // i18n-ignore: a fallback name
  if (s) { model.scenes.push(s); report.cameras++; }
}

In detail

Method
Similarity transform for axes and unit; node matrix = translation × rotation offset × pivot × pre-rotation × rotation × post-rotation⁻¹ × pivot⁻¹ × scaling offset × scaling pivot × scaling × scaling pivot⁻¹.
Number types
64-bit numbers; mesh points in a Float64Array.
Tolerances
Axes whose matrix has |determinant| < 1e-12 are treated as contradictory and only the unit is applied; the test is absolute, and the determinant is the unit factor cubed. A geometric transform within 1e-12 of the identity is ignored.
Limits
The centimeter factor 0.01 is not an exact binary number, so a coordinate can differ in its last bit from the same value divided by 100. With a factor to meters below about 1e-4 (a file unit smaller than about 0.1 mm) the determinant test drops the axis conversion.

Approach: 64-bit, direct

Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.

Every policy that takes this approach