From 054c00f51eab933fbf353e18beb6f175fffc9de4 Mon Sep 17 00:00:00 2001 From: luciano Date: Fri, 31 Jul 2026 12:16:41 +0200 Subject: [PATCH] Arm spread: pose-aware outward axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/model3d.js | 53 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/model3d.js b/src/model3d.js index 13e3611..081aa20 100644 --- a/src/model3d.js +++ b/src/model3d.js @@ -79,6 +79,13 @@ function m3FromTo(a, b) { -cz+cy*cx*k, d+cy*cy*k, cx+cy*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) function m4Rot3(m) { 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) this.restJoints = this._computeJoints(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; let mn = [1e9,1e9,1e9], mx = [-1e9,-1e9,-1e9]; model.prims.forEach(p => { @@ -858,13 +873,31 @@ class ModelSim { // the character faces the camera) by the per-model amount if (anim && this.armSpread) { const b2 = base(n.name); - if (b2 === 'LeftArm' || b2 === 'RightArm') { - const sa = b2 === 'LeftArm' ? this.armSpread : -this.armSpread; - const cs = Math.cos(sa), sn = Math.sin(sa); - const Rz = [cs, sn, 0, -sn, cs, 0, 0, 0, 1]; - const pr = parentWorld ? m4Rot3(parentWorld) : [1,0,0, 0,1,0, 0,0,1]; - 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); + if ((b2 === 'LeftArm' || b2 === 'RightArm') && this._leftAxis) { + // push the arm toward the character's outward side from its + // CURRENT direction: works whether the pose hangs the arms down + // 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 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]]; + r3 = m3Mul(m3Mul(m3Mul(prT, Rf), pr), r3); + } + } } } const s = n.s; @@ -874,7 +907,11 @@ class ModelSim { } const world = parentWorld ? m4mul(parentWorld, local) : local; 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)); }; sk.roots.forEach(r => visit(r, null));