// engine/anim/root-motion.js - written by Muse Glimmer (Phase 32, part A, 27 Sep 2026; raw output in game/muse-authored/phase32/raw/), // reviewed by Grok Bot (coach) with game/muse-authored/phase32/harness/. Coach fixes are marked [coach fix P32-n], see FIXES-P32.md. (function () { function dot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } function sub(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; } function add(a, b) { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; } function scale(a, s) { return [a[0] * s, a[1] * s, a[2] * s]; } function len(v) { return Math.sqrt(dot(v, v)); } function hVec(v, up) { var d = dot(v, up); return [v[0] - d * up[0], v[1] - d * up[1], v[2] - d * up[2]]; } function measure(clip, node, up) { var tracks = clip.tracks || []; var tr = null; for (var i = 0; i < tracks.length; i++) { var t = tracks[i]; if (t.node === node && t.path === 'translation') { tr = t; break; } } if (!tr) return null; var times = tr.times; var values = tr.values; var n = times.length; if (n === 0) return null; var first = [values[0], values[1], values[2]]; var lastIdx = (n - 1) * 3; var last = [values[lastIdx], values[lastIdx + 1], values[lastIdx + 2]]; var net = sub(last, first); var hnet = hVec(net, up); var horiz = len(hnet); var vert = Math.abs(dot(net, up)); var maxHoriz = 0; for (var i = 0; i < n; i++) { var idx = i * 3; var key = [values[idx], values[idx + 1], values[idx + 2]]; var diff = sub(key, first); var hd = hVec(diff, up); var l = len(hd); if (l > maxHoriz) maxHoriz = l; } var duration = clip.duration || 0; var speed = duration > 0 ? horiz / duration : 0; return { keys: n, first: first, last: last, net: net, horiz: horiz, vert: vert, maxHoriz: maxHoriz, speed: speed }; } function strip(clip, node, up, opts) { opts = opts || {}; var mode = opts.mode === 'lock' ? 'lock' : 'drift'; var tracks = clip.tracks || []; var found = false; var newTracks = []; var removed = [0, 0, 0]; for (var i = 0; i < tracks.length; i++) { var t = tracks[i]; if (t.node === node && t.path === 'translation' && t.times.length > 0) { // [coach fix P32-3] an empty track made NaN keys; now treated as no track found = true; var times = t.times; var values = t.values; var n = times.length; var first = [values[0], values[1], values[2]]; var lastIdx = (n - 1) * 3; var last = [values[lastIdx], values[lastIdx + 1], values[lastIdx + 2]]; var net = sub(last, first); var hnet = hVec(net, up); removed = hnet; var newValues = new Float32Array(values.length); newValues.set(values); var t0 = times[0]; var tLast = times[n - 1]; var denom = tLast - t0; for (var k = 0; k < n; k++) { var idx = k * 3; var key = [values[idx], values[idx + 1], values[idx + 2]]; var newKey; if (mode === 'drift') { var u = denom !== 0 ? (times[k] - t0) / denom : 0; var subVec = scale(hnet, u); newKey = sub(key, subVec); } else { var diff = sub(key, first); var hdiff = hVec(diff, up); newKey = sub(key, hdiff); } newValues[idx] = newKey[0]; newValues[idx + 1] = newKey[1]; newValues[idx + 2] = newKey[2]; } var newTrack = Object.assign({}, t, { times: times, values: newValues }); // [coach fix P32-3] keep any other track fields (was a fixed field list) newTracks.push(newTrack); } else { newTracks.push(t); } } if (!found) { return { clip: clip, removed: [0, 0, 0], mode: mode }; } var newClip = { name: clip.name, duration: clip.duration, tracks: newTracks }; return { clip: newClip, removed: removed, mode: mode }; } function pickRoot(skeleton, clips, hipsNode) { var nodes = (skeleton && skeleton.nodes) || []; var candidates = new Set(); for (var i = 0; i < clips.length; i++) { var trs = clips[i].tracks || []; for (var j = 0; j < trs.length; j++) { var t = trs[j]; if (t.path === 'translation') { candidates.add(t.node); } } } function moves(nodeIdx) { for (var i = 0; i < clips.length; i++) { var trs = clips[i].tracks || []; for (var j = 0; j < trs.length; j++) { var t = trs[j]; if (t.node === nodeIdx && t.path === 'translation') { var times = t.times; var values = t.values; var n = times.length; if (n < 2) continue; var first = [values[0], values[1], values[2]]; var maxd = 0; for (var k = 1; k < n; k++) { var idx = k * 3; var key = [values[idx], values[idx + 1], values[idx + 2]]; var d = Math.sqrt( (key[0] - first[0]) * (key[0] - first[0]) + (key[1] - first[1]) * (key[1] - first[1]) + (key[2] - first[2]) * (key[2] - first[2]), ); if (d > maxd) maxd = d; } if (maxd > 1e-6) return true; } } } return false; } if (hipsNode >= 0 && hipsNode < nodes.length) { var chain = []; var cur = hipsNode; while (cur >= 0 && cur < nodes.length && chain.length <= nodes.length) { // [coach fix P32-3] stop on bad / cyclic parent indices (was an endless loop or a TypeError) chain.push(cur); var p = nodes[cur].parent; if (p == null || p < 0) break; cur = p; } for (var i = 0; i < chain.length; i++) { var nIdx = chain[i]; if (candidates.has(nIdx) && moves(nIdx)) { return nIdx; } } } var bestIdx = -1; var bestDepth = Infinity; function depth(idx) { var d = 0; var cur = idx; while (cur >= 0 && cur < nodes.length && d <= nodes.length) { // [coach fix P32-3] cycle guard var p = nodes[cur].parent; if (p == null || p < 0) break; d++; cur = p; } return d; } candidates.forEach(function (nIdx) { if (!moves(nIdx)) return; var d = depth(nIdx); if (d < bestDepth || (d === bestDepth && nIdx < bestIdx)) { bestDepth = d; bestIdx = nIdx; } }); return bestIdx; } var MuseRootMotion = { measure: measure, strip: strip, pickRoot: pickRoot }; if (typeof window !== 'undefined') window.MuseRootMotion = MuseRootMotion; if (typeof module !== 'undefined') module.exports = MuseRootMotion; })();