Resources Numerical accuracy Calculation policy

Animation keys, curves and poses

Where the camera and every moving object are at a given time of an animation, and the value of every animated property.

How the precision is kept

Key values and matrices are kept as 64-bit numbers exactly as given. Key times, the animation length and clip starts are rounded to 0.001 s whenever they are set, and keys are found again by that rounded time. Between keys, positions follow a cubic Hermite curve, rotations a spherical blend of unit quaternions, and Bézier property curves are solved for time by 40 bisection steps.

Calculations under this policy

23 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.

render/anim.js BimCraft 0.8.1 64-bit 23 calculations Reviewed

Tests bin/test-animation.mjs

Key times to one millisecond

64-bit

The rounding applied to every key time, clip start and the animation length.

t = round(t × 1000) ÷ 1000

Used when a key is set, moved, scaled, pasted or baked, and when a key is looked up, so times are compared with exact equality after the same rounding.

  • render/anim.js
  • line 99
  • BimCraft 0.8.1
const r3 = (x) => Math.round(x * 1000) / 1000;

Stretching an animation to a new length

64-bit

Every key time and clip start when the animation is made longer or shorter.

k = new length ÷ old length; t′ = round₀.₀₀₁(t × k)

Each key time is multiplied by the ratio and rounded to 0.001 s, so repeated retiming moves keys by up to half a millisecond each time. Bézier handle offsets are multiplied by the ratio without rounding.

  • render/anim.js
  • lines 398 to 410
  • BimCraft 0.8.1
/** Stretch or squeeze every key to a new length (seconds). */
export function retime(anim, length) {
  if (!fin(length)) return anim;
  const L = clamp(r3(length), 0.5, LIMITS.length), k = L / anim.length;
  const a = copy(anim);
  a.length = L;
  a.camera = sortKeys(a.camera.map((x) => ({ ...x, t: r3(x.t * k) })));
  const stretch = (x) => { const y = { ...x, t: r3(x.t * k) }; if (x.hl) y.hl = [x.hl[0] * k, ...x.hl.slice(1)]; if (x.hr) y.hr = [x.hr[0] * k, ...x.hr.slice(1)]; return y; };
  for (const o of a.objects) o.keys = sortKeys(o.keys.map(stretch));
  for (const p of a.props || []) p.keys = sortKeys(p.keys.map(stretch));
  for (const c of a.clips || []) { c.start = r3(c.start * k); c.scale = clamp(c.scale * k, 0.01, 100); }
  return a;
}

Spreading chosen keys about a time

64-bit

The new times of selected keys scaled away from or toward a pivot time.

t′ = round₀.₀₀₁(pivot + (t − pivot) × f)

64-bit, clamped to the allowed length, then rounded to 0.001 s. Keys that land on the same rounded time are merged by the sort that follows.

  • render/anim.js
  • lines 472 to 485
  • BimCraft 0.8.1
/** Spread the chosen keys out from (or in towards) time `pivot` by factor f. */
export function scaleKeys(anim, sel, pivot, f) {
  if (!fin(f) || f <= 0 || !fin(pivot)) return anim;
  const a = copy(anim);
  for (const { x, ts } of bySel(a, sel)) {
    for (const k of x.keys) if (ts.has(k.t)) {
      k.t = r3(clamp(pivot + (k.t - pivot) * f, 0, LIMITS.length)); grow(a, k.t);
      if (k.hl) k.hl[0] *= f;
      if (k.hr) k.hr[0] *= f;
    }
    x.set(sortKeys(x.keys));
  }
  return a;
}

Frame count and frame times

64-bit

The number of frames a render of the animation has, and the time of each frame.

frames = max(1, round(length × fps) + 1); time of frame i = i ÷ fps

Length times frame rate is rounded to a whole number, plus one for the frame at time zero. Each frame time is its index divided by the frame rate, so the times are not accumulated.

  • render/anim.js
  • lines 538 to 539
  • BimCraft 0.8.1
export const frameCount = (anim, fps = anim.fps) => Math.max(1, Math.round(anim.length * fps) + 1);
export const frameTime = (i, fps) => i / fps;

Position between two keys

64-bit

Which pair of keys a time falls between and how far along that stretch it is.

s = (t − tᵢ) ÷ (tᵢ₊₁ − tᵢ)

A binary search on the key times, then one division. Keys with equal times are merged when keys are sorted, so the divisor is not zero for a cleaned track.

  • render/anim.js
  • lines 541 to 549
  • BimCraft 0.8.1
/** Where t falls among the keys: {i, s} = between keys i and i+1 at s ∈ [0, 1] of the way (before the first / after the last: clamped). */
function locate(keys, t) {
  if (t <= keys[0].t) return { i: 0, s: 0 };
  const n = keys.length;
  if (t >= keys[n - 1].t) return { i: n - 1, s: 0 };
  let lo = 0, hi = n - 1;
  while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (keys[mid].t <= t) lo = mid; else hi = mid; }
  return { i: lo, s: (t - keys[lo].t) / (keys[lo + 1].t - keys[lo].t) };
}

Time on a repeating track

64-bit

The time inside the keys' span that a repeating, ping-pong or offset track plays at.

x = (t − t₀) ÷ P; k = floor(x); f = x − k

The fraction f comes from subtracting the whole number of spans from x, so far from the keys it keeps fewer digits. A span of 1e-9 s or less is not cycled.

  • render/anim.js
  • lines 551 to 565
  • BimCraft 0.8.1
/**
 * Time on a cycling track: t mapped into the keys' span, and n = how many whole spans away it is (for 'offset').
 * → {t, n} (n 0 = inside, or held).
 */
function cycleTime(keys, t, cyc) {
  const n = keys.length;
  if (!cyc || n < 2) return { t, n: 0 };
  const t0 = keys[0].t, t1 = keys[n - 1].t, P = t1 - t0;
  if (P <= 1e-9) return { t, n: 0 };
  const mode = t > t1 ? cyc.after : t < t0 ? cyc.before : 'hold';
  if (mode === 'hold') return { t, n: 0 };
  const x = (t - t0) / P, k = Math.floor(x), f = x - k;
  if (mode === 'pingpong') return { t: k % 2 ? t1 - f * P : t0 + f * P, n: 0 };
  return { t: t0 + f * P, n: mode === 'offset' ? k : 0 };
}

Speed through a key

64-bit

The rate of change of a value at a key, from its two neighbors.

m = (pᵢ₊₁ − pᵢ₋₁) ÷ (tᵢ₊₁ − tᵢ₋₁)

One subtraction and one division per component. End keys and stop or hold keys have no speed.

  • render/anim.js
  • lines 567 to 577
  • BimCraft 0.8.1
/**
 * The speed of a value at key i (per second): a curve through its neighbours (Catmull–Rom on uneven times), nothing at the
 * ends and at 'stop' keys. `get(k)` reads the value (an array) out of a key.
 */
function tangent(keys, i, get) {
  const k = keys[i];
  // a key that then holds still is arrived at slowly too, as is one after a hold
  if (i === 0 || i === keys.length - 1 || k.ease === 'stop' || k.ease === 'hold' || keys[i - 1].ease === 'hold') return null;
  const a = get(keys[i - 1]), b = get(keys[i + 1]), dt = keys[i + 1].t - keys[i - 1].t;
  return a.map((x, j) => (b[j] - x) / dt);
}

Value between two keys

64-bit

A position or other value between two keys: held, straight, or on a smooth curve.

p = h₀₀ p₀ + h₀₁ p₁ + h₁₀ Δt m₀ + h₁₁ Δt m₁

The four cubic Hermite weights are polynomials of s evaluated in 64-bit; they weigh the two key values and the two speeds times the stretch length. At s = 0 the result is the first key value exactly, and at s = 1 the second.

  • render/anim.js
  • lines 579 to 590
  • BimCraft 0.8.1
/** A value between keys i and i+1 at s: Hermite with the keys' speeds, a straight line, or held. */
function between(keys, i, s, get) {
  const k0 = keys[i], k1 = keys[i + 1], p0 = get(k0), p1 = get(k1);
  if (k0.ease === 'hold') return p0.slice();
  if (k0.ease === 'linear') return p0.map((x, j) => x + (p1[j] - x) * s);
  const dt = k1.t - k0.t;
  const m0 = k0.ease === 'stop' ? null : tangent(keys, i, get);
  const m1 = k1.ease === 'stop' ? null : tangent(keys, i + 1, get);
  const s2 = s * s, s3 = s2 * s;
  const h00 = 2 * s3 - 3 * s2 + 1, h10 = s3 - 2 * s2 + s, h01 = -2 * s3 + 3 * s2, h11 = s3 - s2;
  return p0.map((x, j) => h00 * x + h01 * p1[j] + h10 * dt * (m0 ? m0[j] : 0) + h11 * dt * (m1 ? m1[j] : 0));
}

Bézier handles of a property key

64-bit

The two handle points of a key on a property curve.

automatic: handle = key ± (Δt ÷ 3, m × Δt ÷ 3)

Automatic handles lie a third of the way to the neighbor along the key's speed; vector handles point a third of the way at the neighbor; stored handles are added to the key as they are.

  • render/anim.js
  • lines 601 to 622
  • BimCraft 0.8.1
/**
 * The Bézier handles of property key i, as absolute points: {l: [t, v[]], r: [t, v[]]}. Auto handles lie along the
 * Catmull–Rom speed (flat at the ends and at stop/hold keys), a third of the way to the neighbour; vector handles point at
 * the neighbour; stored ones are used as they are. Each handle is kept within its stretch.
 */
export function handlePoints(track, i) {
  const keys = track.keys, k = keys[i], n = k.v.length;
  const prev = keys[i - 1], next = keys[i + 1];
  const dl = prev ? k.t - prev.t : 0, dr = next ? next.t - k.t : 0;
  const m = k.hm === 'vector' ? null : tangent(keys, i, getV);
  const side = (other, d, s) => {
    if ((k.hm === 'free' || k.hm === 'aligned') && k[s < 0 ? 'hl' : 'hr']) {
      const h = k[s < 0 ? 'hl' : 'hr'];
      return [k.t + h[0], k.v.map((v, j) => v + h[j + 1])];
    }
    if (!other) return [k.t + (s * d) / 3, k.v.slice()];
    if (k.hm === 'vector') return [k.t + (s * d) / 3, k.v.map((v, j) => v + (other.v[j] - v) / 3)];
    return [k.t + (s * d) / 3, k.v.map((v, j) => v + (s * (m ? m[j] : 0) * d) / 3)];
  };
  void n;
  return { l: side(prev, dl, -1), r: side(next, dr, 1) };
}

Property value on a Bézier curve

64-bit

The value of an animated property at a time between two keys with Bézier handles.

solve x(u) = t by bisection; v = B(v₀, p₁, p₂, v₃; u)

The handles are shortened by one common factor so the curve cannot run backward in time. The parameter u is found by exactly 40 halvings of [0, 1], with no check of the remaining time error, and the value is then one cubic evaluation.

  • render/anim.js
  • lines 625 to 644
  • BimCraft 0.8.1
/** A property value between keys i and i+1 at time t (the keys' eases; 'bezier' through the handles). */
function propBetween(keys, i, t, s, track) {
  const k0 = keys[i], k1 = keys[i + 1];
  if (k0.ease !== 'bezier') return between(keys, i, s, getV);
  const h0 = handlePoints(track, i).r, h1 = handlePoints(track, i + 1).l;
  const t0 = k0.t, t3 = k1.t, D = t3 - t0;
  // keep the handles from crossing in time (the curve must not run backwards): shrink both by the same factor
  let a = clamp(h0[0] - t0, 0, D), b = clamp(t3 - h1[0], 0, D);
  const f = a + b > D ? D / (a + b) : 1;
  const ra = (h0[0] - t0) > 0 ? (a * f) / (h0[0] - t0) : 0, rb = (t3 - h1[0]) > 0 ? (b * f) / (t3 - h1[0]) : 0;
  a *= f; b *= f;
  const t1 = t0 + a, t2 = t3 - b;
  // solve x(u) = t (x rises with u): bisection, a few steps of it
  let lo = 0, hi = 1, u = s;
  for (let it = 0; it < 40; it++) { u = (lo + hi) / 2; if (bz(t0, t1, t2, t3, u) < t) lo = u; else hi = u; }
  return k0.v.map((v0, j) => {
    const p1 = v0 + (h0[1][j] - v0) * ra, p2 = k1.v[j] + (h1[1][j] - k1.v[j]) * rb;
    return bz(v0, p1, p2, k1.v[j], u);
  });
}

Property value at a time

64-bit

A property track's value at any time, with its cycles followed.

offset cycle: v + n × (last key − first key)

At a key, or after the last one, the key's own value is returned unchanged. An offset cycle adds whole multiples of the difference between the last and first keys.

  • render/anim.js
  • lines 645 to 653
  • BimCraft 0.8.1
/** A property track's value at time t (number[]), its cycles followed. */
export function valueAt(track, t) {
  const keys = track.keys;
  const c = cycleTime(keys, t, track.cycle);
  const { i, s } = locate(keys, c.t);
  let v = s === 0 || i === keys.length - 1 ? keys[i].v.slice() : propBetween(keys, i, c.t, s, track);
  if (c.n) { const a = keys[0].v, b = keys[keys.length - 1].v; v = v.map((x, j) => x + c.n * (b[j] - a[j])); }
  return v;
}

Camera at a time

64-bit

The camera's eye, target, view angle and view height between camera keys.

eye, target, fov on the curve; height = exp(curve of ln height)

The view height is interpolated as its logarithm and taken back with exp. The view angle is clamped to 10° to 120° and the height to 1e-6 to 1e7. An offset cycle adds whole multiples of the difference between the last and first keys to the eye and the target.

  • render/anim.js
  • lines 667 to 687
  • BimCraft 0.8.1
/** The camera at time t (seconds), or null when the camera has no keys. A 'camera' fov track sets the lens. */
export function cameraAt(anim, t) {
  const keys = anim.camera;
  if (!keys.length) return null;
  const cyc = cycleTime(keys, t, anim.cameraCycle);
  const { i, s } = locate(keys, cyc.t);
  let cam;
  if (s === 0 || i === keys.length - 1) cam = cleanCam(keys[i].cam);
  else {
    const v = between(keys, i, s, (k) => [...k.cam.eye, ...k.cam.target, k.cam.fov, Math.log(k.cam.height)]);
    const k = eased(keys[i].ease, s), a = keys[i].cam, b = keys[i + 1].cam;
    cam = cleanCam({ eye: v.slice(0, 3), target: v.slice(3, 6), fov: clamp(v[6], 10, 120), persp: k < 0.5 ? a.persp : b.persp, height: clamp(Math.exp(v[7]), 1e-6, 1e7) }) || cleanCam(a);
  }
  if (cyc.n) {
    const a = keys[0].cam, b = keys[keys.length - 1].cam;
    for (let j = 0; j < 3; j++) { const d = cyc.n * (b.eye[j] - a.eye[j]); cam.eye[j] += d; cam.target[j] += cyc.n * (b.target[j] - a.target[j]); }
  }
  const f = findProp(anim, 'camera', 'fov');
  if (f >= 0) cam.fov = clamp(valueAt(anim.props[f], t)[0], 10, 120);
  return cam;
}

Matrix to position, rotation and scale

64-bit

A placement matrix split into its position, a unit quaternion and three scale factors.

s = column lengths; q from the rotation matrix (Shepperd)

Scales are Math.hypot of the columns; a negative determinant makes the x scale negative. A column is divided by its scale only if the scale is above 1e-12 in size. The branch is chosen by the trace and the largest diagonal term, so for a true rotation the square root's argument is at least 1, and the quaternion is normalized at the end.

  • render/anim.js
  • lines 690 to 709
  • BimCraft 0.8.1
/** A placement matrix as position, rotation (unit quaternion x, y, z, w) and scale (a mirror shows as a negative x scale). */
export function decompose(m) {
  const col = (c) => [m[c * 4], m[c * 4 + 1], m[c * 4 + 2]];
  let x = col(0), y = col(1), z = col(2);
  let sx = Math.hypot(...x), sy = Math.hypot(...y), sz = Math.hypot(...z);
  const det = x[0] * (y[1] * z[2] - y[2] * z[1]) - x[1] * (y[0] * z[2] - y[2] * z[0]) + x[2] * (y[0] * z[1] - y[1] * z[0]);
  if (det < 0) sx = -sx;
  const n = (v, s) => (Math.abs(s) > 1e-12 ? v.map((u) => u / s) : v);
  x = n(x, sx); y = n(y, sy); z = n(z, sz);
  // rotation matrix → quaternion (Shepperd)
  const [m00, m10, m20] = x, [m01, m11, m21] = y, [m02, m12, m22] = z;
  const tr = m00 + m11 + m22;
  let q;
  if (tr > 0) { const S = Math.sqrt(tr + 1) * 2; q = [(m21 - m12) / S, (m02 - m20) / S, (m10 - m01) / S, 0.25 * S]; }
  else if (m00 > m11 && m00 > m22) { const S = Math.sqrt(1 + m00 - m11 - m22) * 2; q = [0.25 * S, (m01 + m10) / S, (m02 + m20) / S, (m21 - m12) / S]; }
  else if (m11 > m22) { const S = Math.sqrt(1 + m11 - m00 - m22) * 2; q = [(m01 + m10) / S, 0.25 * S, (m12 + m21) / S, (m02 - m20) / S]; }
  else { const S = Math.sqrt(1 + m22 - m00 - m11) * 2; q = [(m02 + m20) / S, (m12 + m21) / S, 0.25 * S, (m10 - m01) / S]; }
  const ql = Math.hypot(...q) || 1;
  return { t: [m[12], m[13], m[14]], q: q.map((v) => v / ql), s: [sx, sy, sz] };
}

Position, rotation and scale to matrix

64-bit

The placement matrix built from a position, a quaternion and a scale.

Products of quaternion components in 64-bit; each rotation column is multiplied by its scale. The quaternion is taken as unit length and is not renormalized here.

  • render/anim.js
  • lines 710 to 719
  • BimCraft 0.8.1
export function compose({ t, q, s }) {
  const [x, y, z, w] = q;
  const xx = x * x, yy = y * y, zz = z * z, xy = x * y, xz = x * z, yz = y * z, wx = w * x, wy = w * y, wz = w * z;
  return [
    (1 - 2 * (yy + zz)) * s[0], 2 * (xy + wz) * s[0], 2 * (xz - wy) * s[0], 0,
    2 * (xy - wz) * s[1], (1 - 2 * (xx + zz)) * s[1], 2 * (yz + wx) * s[1], 0,
    2 * (xz + wy) * s[2], 2 * (yz - wx) * s[2], (1 - 2 * (xx + yy)) * s[2], 0,
    t[0], t[1], t[2], 1,
  ];
}

Blend of two rotations

64-bit

The rotation part of a pose between two keys.

q = (sin((1 − k)θ) a + sin(kθ) b) ÷ sin θ, θ = acos(a · b)

The second quaternion is negated if the dot product is negative. When the dot product is above 0.9995 the blend is linear and renormalized, which avoids dividing by a small sine; otherwise the acos argument is clamped to 1.

  • render/anim.js
  • lines 720 to 729
  • BimCraft 0.8.1
/** Spherical blend of two unit quaternions, the short way round. */
export function slerp(a, b, k) {
  let d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
  const bb = d < 0 ? b.map((v) => -v) : b;
  d = Math.abs(d);
  if (d > 0.9995) { const r = a.map((v, i) => v + (bb[i] - v) * k), l = Math.hypot(...r) || 1; return r.map((v) => v / l); }
  const th = Math.acos(Math.min(1, d)), sn = Math.sin(th);
  const wa = Math.sin((1 - k) * th) / sn, wb = Math.sin(k * th) / sn;
  return a.map((v, i) => v * wa + bb[i] * wb);
}

Angles to quaternion

64-bit

XYZ rotation angles in degrees turned into a unit quaternion.

q = q_z × q_y × q_x, each from sin and cos of half the angle

Degrees are multiplied by π ÷ 180 and halved; the three axis quaternions are multiplied in 64-bit.

  • render/anim.js
  • lines 737 to 742
  • BimCraft 0.8.1
/** XYZ angles in degrees (turned about X first, then Y, then Z, all in the parent's axes) → a unit quaternion. */
export function eulerToQuat(e) {
  const h = (a) => (a * DEG) / 2;
  const qx = [Math.sin(h(e[0])), 0, 0, Math.cos(h(e[0]))], qy = [0, Math.sin(h(e[1])), 0, Math.cos(h(e[1]))], qz = [0, 0, Math.sin(h(e[2])), Math.cos(h(e[2]))];
  return qmul(qz, qmul(qy, qx));
}

Quaternion to angles

64-bit

XYZ rotation angles in degrees from a unit quaternion, chosen close to the key before.

The asin argument is clamped to [−1, 1]. Within 0.000001 of straight up or down the X and Z turns are folded into X. Of the equivalent answers, the one with the smaller summed angle difference from the previous key is kept, the alternative winning only by more than 1e-9°.

  • render/anim.js
  • lines 743 to 761
  • BimCraft 0.8.1
/** A unit quaternion → XYZ angles in degrees; `near` (angles) picks, of the equal answers, the one closest to it (keys in a row). */
export function quatToEuler(q, near) {
  const [x, y, z, w] = q;
  const r00 = 1 - 2 * (y * y + z * z), r10 = 2 * (x * y + w * z), r20 = 2 * (x * z - w * y);
  const r21 = 2 * (y * z + w * x), r22 = 1 - 2 * (x * x + y * y), r01 = 2 * (x * y - w * z), r11 = 1 - 2 * (x * x + z * z);
  let e;
  const sy = clamp(-r20, -1, 1);
  if (Math.abs(sy) < 0.999999) e = [Math.atan2(r21, r22), Math.asin(sy), Math.atan2(r10, r00)];
  else e = [Math.atan2(r01 * sy, r11), Math.asin(sy), 0];   // straight up or down: X and Z turn about one axis
  e = e.map((a) => a / DEG);
  if (!near) return e;
  // the other answer for the same turn, then whole turns, whichever lies nearest
  const alt = [e[0] + 180, 180 - e[1], e[2] + 180];
  const wrap = (a, n) => a + 360 * Math.round((n - a) / 360);
  const fit = (c) => c.map((a, j) => wrap(a, near[j]));
  const A = fit(e), B = fit(alt);
  const dist = (c) => c.reduce((s, a, j) => s + Math.abs(a - near[j]), 0);
  return dist(B) + 1e-9 < dist(A) ? B : A;
}

Object placement at a time

64-bit

A moving object's placement matrix between two matrix keys.

Position follows the Hermite curve, rotation the quaternion blend, scale a straight line, and the matrix is rebuilt from the three. At a key the key's own matrix is returned unchanged, so a matrix with shear is exact at keys but loses the shear between them.

  • render/anim.js
  • lines 769 to 786
  • BimCraft 0.8.1
/** One object's placement matrix at time t: its position follows the keys' curve, its turn and size blend between keys. */
export function poseAt(track, t) {
  const keys = track.keys;
  const cyc = cycleTime(keys, t, track.cycle);
  const { i, s } = locate(keys, cyc.t);
  let m;
  if (s === 0 || i === keys.length - 1) m = keys[i].m.slice();
  else {
    const d = (k) => (k._d || (Object.defineProperty(k, '_d', { value: decompose(k.m), enumerable: false }), k._d));
    const pos = between(keys, i, s, (k) => d(k).t);
    const k = eased(keys[i].ease, s), a = d(keys[i]), b = d(keys[i + 1]);
    const q = slerp(a.q, b.q, k);
    const sc = a.s.map((v, j) => v + (b.s[j] - v) * k);
    m = compose({ t: pos, q, s: sc });
  }
  if (cyc.n) { const a = keys[0].m, b = keys[keys.length - 1].m; for (let j = 12; j < 15; j++) m[j] += cyc.n * (b[j] - a[j]); }
  return m;
}

Time inside a clip

64-bit

The time of a saved action that a clip plays at a given timeline time.

x = (t − start) ÷ scale; local = from + (x mod span)

Clip ends are compared within 1e-9 s (absolute). The repeat uses the 64-bit remainder of the played time by the span.

  • render/anim.js
  • lines 788 to 798
  • BimCraft 0.8.1
/** The time inside an action that clip c plays at timeline time t, or null when the clip isn't playing then. */
function clipLocal(c, t) {
  const span = c.to - c.from, L = Math.max(span, 1e-6) * c.scale * c.repeat;
  if (c.mute) return null;
  if (t < c.start - 1e-9) return c.extend === 'both' ? c.from : null;
  if (t > c.start + L + 1e-9) return c.extend === 'none' ? null : c.to;
  if (span <= 1e-9) return c.from;
  const x = (t - c.start) / c.scale;   // seconds of action played
  if (x >= span * c.repeat - 1e-9) return c.to;   // the last frame of the last repeat
  return c.from + (x % span);
}

Mixing a clip into a pose

64-bit

An object's placement when a clip is blended in at less than full influence or added on top.

Both placements are decomposed; positions and scales are blended linearly and rotations with the quaternion blend, then recomposed. An added clip contributes its change from its own first key.

  • render/anim.js
  • lines 837 to 845
  • BimCraft 0.8.1
const A = decompose(under), B = decompose(p.m);
if (c.blend === 'add') {
  // what the action adds: its move away from its own first pose
  const tr = act.objects.find((o) => pathKey(o.path) === k);
  const first = tr ? decompose(tr.keys[0].m) : null;
  const dq = first ? qmul(B.q, [-first.q[0], -first.q[1], -first.q[2], first.q[3]]) : B.q;
  const dt = first ? B.t.map((v, j) => v - first.t[j]) : [0, 0, 0];
  poses.set(k, { path: p.path, m: compose({ t: A.t.map((v, j) => v + w * dt[j]), q: slerp(A.q, qmul(dq, A.q), w), s: A.s }) });
} else poses.set(k, { path: p.path, m: compose({ t: A.t.map((v, j) => v + (B.t[j] - v) * w), q: slerp(A.q, B.q, w), s: A.s.map((v, j) => v + (B.s[j] - v) * w) }) });

Driver expression

64-bit

The value of a property worked out from other properties by a typed expression.

The expression is run as steps on a Float64Array stack with JavaScript's own operators and Math functions; no rounding is applied. A result that is not a finite number becomes 0, and a missing variable is read as 0.

  • render/anim.js
  • lines 1122 to 1137
  • BimCraft 0.8.1
const run = (vars) => {
  const st = new Float64Array(steps.length + 1);
  let n = 0;
  for (const s2 of steps) {
    if (s2.k === 'num') st[n++] = s2.v;
    else if (s2.k === 'var') { const x = vars && vars[s2.v]; st[n++] = typeof x === 'number' && Number.isFinite(x) ? x : 0; }
    else if (s2.k === 'fn') { const [c, f] = FUNCS[s2.v]; n -= c; const args = []; for (let q = 0; q < c; q++) args.push(st[n + q]); st[n++] = f(...args); }
    else if (s2.v === 'neg') st[n - 1] = -st[n - 1];
    else {
      const b = st[--n], a = st[n - 1];
      st[n - 1] = s2.v === '+' ? a + b : s2.v === '-' ? a - b : s2.v === '*' ? a * b : s2.v === '/' ? a / b : s2.v === '%' ? a % b : Math.pow(a, b);
    }
  }
  const r = st[0];
  return Number.isFinite(r) ? r : 0;
};

Camera keys from saved scenes

64-bit

The key times of a walkthrough built from saved scenes with a move time and a hold time.

t accumulates move and hold; key time = round₀.₀₀₁(t)

The running time is kept unrounded and each key time is rounded from it, so the rounding does not accumulate from key to key.

  • render/anim.js
  • lines 955 to 970
  • BimCraft 0.8.1
/**
 * Camera keys from saved scenes, in order (the walkthrough video): each scene is held for `hold` seconds, and the camera
 * moves to the next one over `move` seconds, slowing into every scene. → {keys, length}
 */
export function fromScenes(scenes, { move = 3, hold = 1.5 } = {}) {
  const keys = [];
  let t = 0;
  scenes.forEach((sc, i) => {
    const cam = sc && cleanCam(sc.cam);
    if (!cam) return;
    if (keys.length) t += move;
    keys.push({ t: r3(t), cam, ease: hold > 0 ? 'hold' : 'stop' });
    if (hold > 0) { t += hold; keys.push({ t: r3(t), cam: { ...cam, eye: cam.eye.slice(), target: cam.target.slice() }, ease: 'stop' }); }
  });
  return { keys: sortKeys(keys), length: Math.max(0.5, r3(t)) };
}

Default gravity for rigid bodies

64-bit

The gravity a physics bake starts from when the file sets none.

g = (0, 0, −9.81) m/s²

The default is 9.81 m/s² downward, a rounded value; standard gravity is 9.80665 m/s².

  • render/anim.js
  • lines 1339 to 1340
  • BimCraft 0.8.1
// ---- rigid bodies (0.7 AN.5): the settings; anim/physics.js simulates ----
export const PHYSICS_DEFAULTS = Object.freeze({ gravity: [0, 0, -9.81], ground: { on: true, z: 0 }, from: 0, to: 3, substeps: 20, bodies: [] });

In detail

Method
Catmull-Rom speeds on uneven key times with cubic Hermite blending; cubic Bézier property curves with the time coordinate solved by bisection; matrix to quaternion by Shepperd's method (as the code's comment names it); spherical linear interpolation; XYZ Euler angles in degrees.
Number types
64-bit numbers in plain arrays; Float64Array for sampled curves and the expression stack. Matrices are 16 numbers, column-major.
Tolerances
Key times rounded to 0.001 s. A cycle span of 1e-9 s or less is not cycled. Scale below 1e-12 is not divided out. Quaternions with a dot product above 0.9995 are blended linearly and renormalized. Gimbal lock when |sin| ≥ 0.999999. Clip ends compared within 1e-9 s.
Limits
Two keys cannot be closer than 0.001 s. The Bézier time solve stops after 40 halvings of the parameter interval, with no test of the remaining error. Rotation between matrix keys always takes the short way round.

Approach: 64-bit, stated limit

The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.

Every policy that takes this approach