Arm spread: pose-aware outward axis

The fixed front-axis rotation degenerated on poses with the arms bent
forward (vector nearly parallel to the axis — why the mannequin showed
no effect on salsa-like moves). The swing axis is now computed per
frame as cross(current arm direction, character outward side), so the
arm is always pushed away from the torso regardless of the pose, and
the rotation naturally vanishes once the arm already points outward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-31 12:16:41 +02:00
1 parent a69cac0ebd
commit 054c00f51e
1 file changed
+43 -6
+43 -6
View File
@@ -79,6 +79,13 @@ function m3FromTo(a, b) {
-cz+cy*cx*k, d+cy*cy*k, cx+cy*cz*k, -cz+cy*cx*k, d+cy*cy*k, cx+cy*cz*k,
cy+cz*cx*k, -cx+cz*cy*k, d+cz*cz*k]; cy+cz*cx*k, -cx+cz*cy*k, d+cz*cz*k];
} }
// rotation of `ang` radians around a unit axis (Rodrigues), column-major mat3
function m3AxisAngle(ax, ang) {
const [x, y, z] = ax, c = Math.cos(ang), s = Math.sin(ang), t = 1 - c;
return [t*x*x + c, t*x*y + s*z, t*x*z - s*y,
t*x*y - s*z, t*y*y + c, t*y*z + s*x,
t*x*z + s*y, t*y*z - s*x, t*z*z + c];
}
// mat4 rotation part with the scale stripped (for solving in world frames) // mat4 rotation part with the scale stripped (for solving in world frames)
function m4Rot3(m) { function m4Rot3(m) {
const n = (x,y,z) => { const l = Math.hypot(x,y,z) || 1; return [x/l, y/l, z/l]; }; const n = (x,y,z) => { const l = Math.hypot(x,y,z) || 1; return [x/l, y/l, z/l]; };
@@ -543,6 +550,14 @@ class ModelSim {
// bounding box (skinned vertices live in mesh space until deformed) // bounding box (skinned vertices live in mesh space until deformed)
this.restJoints = this._computeJoints(null); this.restJoints = this._computeJoints(null);
this._hipsRest = this._hipsW ? this._hipsW.slice() : null; this._hipsRest = this._hipsW ? this._hipsW.slice() : null;
// the character's outward (left) axis from the rest shoulder line
this._leftAxis = null;
if (this._armLW && this._armRW) {
const lx = this._armLW[0]-this._armRW[0], ly = this._armLW[1]-this._armRW[1],
lz = this._armLW[2]-this._armRW[2];
const ll = Math.hypot(lx, ly, lz);
if (ll > 1e-6) this._leftAxis = [lx/ll, ly/ll, lz/ll];
}
const J = this.restJoints; const J = this.restJoints;
let mn = [1e9,1e9,1e9], mx = [-1e9,-1e9,-1e9]; let mn = [1e9,1e9,1e9], mx = [-1e9,-1e9,-1e9];
model.prims.forEach(p => { model.prims.forEach(p => {
@@ -858,13 +873,31 @@ class ModelSim {
// the character faces the camera) by the per-model amount // the character faces the camera) by the per-model amount
if (anim && this.armSpread) { if (anim && this.armSpread) {
const b2 = base(n.name); const b2 = base(n.name);
if (b2 === 'LeftArm' || b2 === 'RightArm') { if ((b2 === 'LeftArm' || b2 === 'RightArm') && this._leftAxis) {
const sa = b2 === 'LeftArm' ? this.armSpread : -this.armSpread; // push the arm toward the character's outward side from its
const cs = Math.cos(sa), sn = Math.sin(sa); // CURRENT direction: works whether the pose hangs the arms down
const Rz = [cs, sn, 0, -sn, cs, 0, 0, 0, 1]; // or bends them forward (a fixed axis dies on forward poses)
let ci = -1;
const cb = b2 === 'LeftArm' ? 'LeftForeArm' : 'RightForeArm';
for (const c of n.children) if (base(sk.nodes[c].name) === cb) { ci = c; break; }
if (ci >= 0) {
const pr = parentWorld ? m4Rot3(parentWorld) : [1,0,0, 0,1,0, 0,0,1]; const pr = parentWorld ? m4Rot3(parentWorld) : [1,0,0, 0,1,0, 0,0,1];
const cl = v3norm(sk.nodes[ci].t);
const wr = m3Mul(pr, r3);
const dir = [wr[0]*cl[0]+wr[3]*cl[1]+wr[6]*cl[2],
wr[1]*cl[0]+wr[4]*cl[1]+wr[7]*cl[2],
wr[2]*cl[0]+wr[5]*cl[1]+wr[8]*cl[2]];
const la = this._leftAxis;
const out = b2 === 'LeftArm' ? la : [-la[0], -la[1], -la[2]];
const ax = [dir[1]*out[2]-dir[2]*out[1], dir[2]*out[0]-dir[0]*out[2],
dir[0]*out[1]-dir[1]*out[0]];
const al = Math.hypot(ax[0], ax[1], ax[2]);
if (al > 1e-4) {
const Rf = m3AxisAngle([ax[0]/al, ax[1]/al, ax[2]/al], this.armSpread);
const prT = [pr[0],pr[3],pr[6], pr[1],pr[4],pr[7], pr[2],pr[5],pr[8]]; const prT = [pr[0],pr[3],pr[6], pr[1],pr[4],pr[7], pr[2],pr[5],pr[8]];
r3 = m3Mul(m3Mul(m3Mul(prT, Rz), pr), r3); r3 = m3Mul(m3Mul(m3Mul(prT, Rf), pr), r3);
}
}
} }
} }
const s = n.s; const s = n.s;
@@ -874,7 +907,11 @@ class ModelSim {
} }
const world = parentWorld ? m4mul(parentWorld, local) : local; const world = parentWorld ? m4mul(parentWorld, local) : local;
worlds[ni] = world; worlds[ni] = world;
if (base(n.name) === 'Hips') this._hipsW = [world[12], world[13], world[14]]; const bn = base(n.name);
if (bn === 'Hips') this._hipsW = [world[12], world[13], world[14]];
else if (bn === 'LeftArm') this._armLW = [world[12], world[13], world[14]];
else if (bn === 'RightArm') this._armRW = [world[12], world[13], world[14]];
else if (bn === 'LeftForeArm') this._foreLW = [world[12], world[13], world[14]];
n.children.forEach(c => visit(c, world)); n.children.forEach(c => visit(c, world));
}; };
sk.roots.forEach(r => visit(r, null)); sk.roots.forEach(r => visit(r, null));