@ninkch/dsh-infinite-canvas 0.19.0 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -659,6 +659,133 @@ export const BONE_POSES = [
659
659
  { bone: 'LeftUpLeg', to: [0.26, 0.10, 0.96] }, { bone: 'LeftLeg', to: [0.10, -0.86, 0.50] },
660
660
  { bone: 'LeftFoot', to: [0.06, -0.62, 0.78] },
661
661
  ] },
662
+
663
+ // ============================================================
664
+ // 打斗动作库(依据参考片段 6:20–6:30 的动作语义编写,方向式)
665
+ // 命名:spear_* / block_* / dodge_* / hit_* / ult_*
666
+ // 配合 poseAnim 串成打斗序列;每个动作都可单帧关键帧使用
667
+ // ============================================================
668
+ // ① 侧身持枪起势:侧身对敌、双手前后持杆、重心下沉
669
+ { id: 'spear_ready', zh: '持枪起势', en: 'Spear ready',
670
+ base: { Spine: [0.10, 0.42, 0], Spine1: [0.06, 0.20, 0], Hips: [0.02, 0.16, 0], Neck: [0.06, -0.30, 0] },
671
+ dirs: [
672
+ { bone: 'RightArm', to: [-0.10, -0.30, 0.95] }, { bone: 'RightForeArm', to: [-0.04, -0.10, 1.0] },
673
+ { bone: 'LeftArm', to: [0.16, -0.42, 0.90] }, { bone: 'LeftForeArm', to: [0.06, -0.24, 0.97] },
674
+ { bone: 'LeftUpLeg', to: [0.16, -0.92, 0.36] }, { bone: 'RightUpLeg', to: [-0.16, -0.94, -0.30] },
675
+ { bone: 'LeftLeg', to: [0.06, -0.97, 0.22] }, { bone: 'RightLeg', to: [-0.06, -0.97, 0.20] },
676
+ ] },
677
+ // ② 横枪突刺:腰部拧转、双手前推、前腿弓步
678
+ { id: 'spear_thrust', zh: '横枪突刺', en: 'Spear thrust',
679
+ base: { Spine: [0.16, -0.46, 0], Spine1: [0.10, -0.24, 0], Hips: [0.04, -0.30, 0], Neck: [0.08, 0.34, 0] },
680
+ dirs: [
681
+ { bone: 'RightArm', to: [-0.06, 0.02, 1.0] }, { bone: 'RightForeArm', to: [-0.04, 0.02, 1.0] },
682
+ { bone: 'LeftArm', to: [0.12, -0.10, 0.98] }, { bone: 'LeftForeArm', to: [0.06, -0.04, 1.0] },
683
+ { bone: 'LeftUpLeg', to: [0.10, -0.88, 0.46] }, { bone: 'LeftLeg', to: [0.04, -0.97, 0.22] },
684
+ { bone: 'RightUpLeg', to: [-0.12, -0.98, -0.16] }, { bone: 'RightLeg', to: [-0.04, -1.0, 0.06] },
685
+ { bone: 'RightFoot', to: [-0.04, -0.40, 0.70] },
686
+ ] },
687
+ // ③ 单掌格挡:单臂上架、掌面外翻、躯干侧倾卸力
688
+ { id: 'palm_block', zh: '单掌格挡', en: 'Palm block',
689
+ base: { Spine: [-0.14, 0.34, 0], Spine1: [-0.08, 0.18, 0], Hips: [-0.04, 0.12, 0], Neck: [0.10, -0.26, 0] },
690
+ dirs: [
691
+ { bone: 'RightArm', to: [-0.52, 0.66, 0.54] }, { bone: 'RightForeArm', to: [-0.46, 0.86, 0.22] },
692
+ { bone: 'LeftArm', to: [0.24, -0.62, 0.74] }, { bone: 'LeftForeArm', to: [0.12, 0.10, 0.98] },
693
+ { bone: 'LeftUpLeg', to: [0.14, -0.94, 0.30] }, { bone: 'RightUpLeg', to: [-0.14, -0.92, -0.34] },
694
+ { bone: 'LeftLeg', to: [0.04, -0.98, 0.18] }, { bone: 'RightLeg', to: [-0.04, -0.98, 0.16] },
695
+ ] },
696
+ // ④ 后仰闪避:上身大幅后仰、髋部后坐、双手回收护身
697
+ { id: 'dodge_lean', zh: '后仰闪避', en: 'Lean back dodge',
698
+ base: { Spine: [-0.62, 0.20, 0], Spine1: [-0.40, 0.12, 0], Hips: [0.18, 0.08, 0], Neck: [0.30, -0.16, 0] },
699
+ dirs: [
700
+ { bone: 'RightArm', to: [-0.30, -0.30, 0.90] }, { bone: 'RightForeArm', to: [-0.18, 0.34, 0.92] },
701
+ { bone: 'LeftArm', to: [0.30, -0.34, 0.88] }, { bone: 'LeftForeArm', to: [0.18, 0.30, 0.94] },
702
+ { bone: 'LeftUpLeg', to: [0.18, -0.86, 0.48] }, { bone: 'LeftLeg', to: [0.06, -0.96, 0.26] },
703
+ { bone: 'RightUpLeg', to: [-0.18, -0.90, -0.38] }, { bone: 'RightLeg', to: [-0.06, -0.98, 0.16] },
704
+ ] },
705
+ // ⑤ 蹬地冲刺:前腿蹬直、后腿抬起、上身前压、双臂前后摆
706
+ { id: 'dash_forward', zh: '蹬地冲刺', en: 'Dash',
707
+ base: { Spine: [0.46, 0.10, 0], Spine1: [0.28, 0.06, 0], Hips: [0.16, 0.04, 0], Neck: [-0.34, -0.08, 0] },
708
+ dirs: [
709
+ { bone: 'RightArm', to: [-0.20, -0.72, 0.66] }, { bone: 'RightForeArm', to: [-0.10, 0.20, 0.97] },
710
+ { bone: 'LeftArm', to: [0.24, 0.30, -0.92] }, { bone: 'LeftForeArm', to: [0.12, 0.10, -0.98] },
711
+ { bone: 'LeftUpLeg', to: [0.10, -0.96, 0.26] }, { bone: 'LeftLeg', to: [0.04, -1.0, 0.02] },
712
+ { bone: 'RightUpLeg', to: [-0.10, 0.52, -0.84] }, { bone: 'RightLeg', to: [-0.06, -0.30, -0.95] },
713
+ { bone: 'RightFoot', to: [-0.04, -0.20, -0.98] },
714
+ ] },
715
+ // ⑥ 压上发狠:上身前探、双臂收紧、下颌压低(近身压制的瞬间)
716
+ { id: 'grit_advance', zh: '压上发狠', en: 'Grit advance',
717
+ base: { Spine: [0.34, -0.18, 0], Spine1: [0.22, -0.10, 0], Hips: [0.10, -0.12, 0], Neck: [0.16, 0.14, 0] },
718
+ dirs: [
719
+ { bone: 'RightArm', to: [-0.14, -0.44, 0.88] }, { bone: 'RightForeArm', to: [-0.06, 0.26, 0.96] },
720
+ { bone: 'LeftArm', to: [0.16, -0.50, 0.84] }, { bone: 'LeftForeArm', to: [0.06, 0.20, 0.97] },
721
+ { bone: 'LeftUpLeg', to: [0.12, -0.92, 0.38] }, { bone: 'LeftLeg', to: [0.04, -0.98, 0.18] },
722
+ { bone: 'RightUpLeg', to: [-0.12, -0.94, -0.32] }, { bone: 'RightLeg', to: [-0.04, -0.98, 0.20] },
723
+ ] },
724
+ // ⑦ 低身横扫:深蹲转体、持杆手贴地扫出、另一手撑地
725
+ { id: 'low_sweep', zh: '低身横扫', en: 'Low sweep',
726
+ base: { Spine: [0.72, -0.52, 0], Spine1: [0.42, -0.28, 0], Hips: [0.30, -0.24, 0.08], Neck: [-0.30, 0.20, 0] },
727
+ rootY: -0.29,
728
+ dirs: [
729
+ { bone: 'RightArm', to: [-0.34, -0.86, 0.36] }, { bone: 'RightForeArm', to: [-0.16, -0.96, 0.22] },
730
+ { bone: 'LeftArm', to: [0.40, -0.80, 0.44] }, { bone: 'LeftForeArm', to: [0.18, -0.94, 0.28] },
731
+ // 蹲姿腿(脚落地):大腿前伸下压、小腿竖直、脚掌踩平
732
+ { bone: 'LeftUpLeg', to: [0.26, -0.34, 0.90] }, { bone: 'RightUpLeg', to: [-0.26, -0.34, 0.90] },
733
+ { bone: 'LeftLeg', to: [0.05, -1.0, 0.04] }, { bone: 'RightLeg', to: [-0.05, -1.0, 0.04] },
734
+ { bone: 'LeftFoot', to: [0.04, -0.06, 1.0] }, { bone: 'RightFoot', to: [-0.04, -0.06, 1.0] },
735
+ ] },
736
+ // ⑧ 枪尖戳地:单膝下沉、持杆手直下压、上身俯冲
737
+ { id: 'spear_ground', zh: '枪尖戳地', en: 'Spear to ground',
738
+ base: { Spine: [0.86, -0.20, 0], Spine1: [0.48, -0.12, 0], Hips: [0.34, -0.10, 0.06], Neck: [-0.48, 0.10, 0] },
739
+ rootY: -0.29,
740
+ dirs: [
741
+ { bone: 'RightArm', to: [-0.16, -0.96, 0.20] }, { bone: 'RightForeArm', to: [-0.08, -0.99, 0.10] },
742
+ { bone: 'LeftArm', to: [0.30, -0.72, 0.62] }, { bone: 'LeftForeArm', to: [0.14, -0.30, 0.94] },
743
+ // 蹲姿腿(脚落地):大腿前伸下压、小腿竖直、脚掌踩平
744
+ { bone: 'LeftUpLeg', to: [0.26, -0.34, 0.90] }, { bone: 'RightUpLeg', to: [-0.26, -0.34, 0.90] },
745
+ { bone: 'LeftLeg', to: [0.05, -1.0, 0.04] }, { bone: 'RightLeg', to: [-0.05, -1.0, 0.04] },
746
+ { bone: 'LeftFoot', to: [0.04, -0.06, 1.0] }, { bone: 'RightFoot', to: [-0.04, -0.06, 1.0] },
747
+ ] },
748
+ // ⑨ 俯身贴地收势:单膝跪地、上身压到近地、持杆手垂落
749
+ { id: 'crouch_low', zh: '俯身贴地', en: 'Crouch low',
750
+ base: { Spine: [0.94, 0.18, 0], Spine1: [0.52, 0.10, 0], Hips: [0.38, 0.08, 0], Neck: [-0.54, -0.12, 0] },
751
+ rootY: -0.29,
752
+ dirs: [
753
+ { bone: 'RightArm', to: [-0.24, -0.92, 0.30] }, { bone: 'RightForeArm', to: [-0.10, -0.98, 0.14] },
754
+ { bone: 'LeftArm', to: [0.34, -0.86, 0.38] }, { bone: 'LeftForeArm', to: [0.16, -0.96, 0.22] },
755
+ // 蹲姿腿(脚落地):大腿前伸下压、小腿竖直、脚掌踩平
756
+ { bone: 'LeftUpLeg', to: [0.26, -0.34, 0.90] }, { bone: 'RightUpLeg', to: [-0.26, -0.34, 0.90] },
757
+ { bone: 'LeftLeg', to: [0.05, -1.0, 0.04] }, { bone: 'RightLeg', to: [-0.05, -1.0, 0.04] },
758
+ { bone: 'LeftFoot', to: [0.04, -0.06, 1.0] }, { bone: 'RightFoot', to: [-0.04, -0.06, 1.0] },
759
+ ] },
760
+ // ⑩ 大招起势:抬头挺胸、双臂外展上举、重心拔起
761
+ { id: 'ult_charge', zh: '大招起势', en: 'Ultimate charge',
762
+ base: { Spine: [-0.28, 0, 0], Spine1: [-0.18, 0, 0], Hips: [-0.10, 0, 0], Neck: [-0.26, 0, 0] },
763
+ rootY: 0.10,
764
+ dirs: [
765
+ { bone: 'RightArm', to: [-0.62, 0.62, 0.48] }, { bone: 'RightForeArm', to: [-0.48, 0.84, 0.26] },
766
+ { bone: 'LeftArm', to: [0.62, 0.62, 0.48] }, { bone: 'LeftForeArm', to: [0.48, 0.84, 0.26] },
767
+ { bone: 'LeftUpLeg', to: [0.14, -0.94, 0.30] }, { bone: 'RightUpLeg', to: [-0.14, -0.94, -0.30] },
768
+ { bone: 'LeftLeg', to: [0.04, -0.99, 0.14] }, { bone: 'RightLeg', to: [-0.04, -0.99, 0.14] },
769
+ ] },
770
+ // ⑪ 受击后仰:躯干后折、双臂散开、后腿撤步卸力
771
+ { id: 'hit_recoil', zh: '受击后仰', en: 'Hit recoil',
772
+ base: { Spine: [-0.78, 0.24, 0.10], Spine1: [-0.48, 0.14, 0.06], Hips: [0.24, 0.10, -0.06], Neck: [0.40, -0.20, 0] },
773
+ dirs: [
774
+ { bone: 'RightArm', to: [-0.66, 0.30, 0.68] }, { bone: 'RightForeArm', to: [-0.52, -0.10, 0.84] },
775
+ { bone: 'LeftArm', to: [0.70, 0.24, 0.66] }, { bone: 'LeftForeArm', to: [0.56, -0.16, 0.80] },
776
+ { bone: 'LeftUpLeg', to: [0.20, -0.80, 0.56] }, { bone: 'LeftLeg', to: [0.08, -0.96, 0.26] },
777
+ { bone: 'RightUpLeg', to: [-0.24, -0.86, -0.44] }, { bone: 'RightLeg', to: [-0.08, -0.98, 0.14] },
778
+ ] },
779
+ // ⑫ 倒地:全身放倒、四肢摊开
780
+ { id: 'knockdown', zh: '倒地', en: 'Knockdown',
781
+ base: { Hips: [-1.52, 0.20, 0.10], Spine: [0.40, 0.10, 0.04], Spine1: [0.26, 0.06, 0], Neck: [0.22, -0.14, 0] },
782
+ rootY: -0.70,
783
+ dirs: [
784
+ { bone: 'LeftUpLeg', to: [0.34, -0.10, 0.94] }, { bone: 'LeftLeg', to: [0.16, -0.44, 0.88] },
785
+ { bone: 'RightUpLeg', to: [-0.30, -0.06, 0.95] }, { bone: 'RightLeg', to: [-0.14, -0.38, 0.92] },
786
+ { bone: 'LeftArm', to: [0.78, 0.22, 0.58] }, { bone: 'RightArm', to: [-0.80, 0.18, 0.56] },
787
+ { bone: 'LeftForeArm', to: [0.62, 0.06, 0.78] }, { bone: 'RightForeArm', to: [-0.64, 0.02, 0.76] },
788
+ ] },
662
789
  ];
663
790
 
664
791
 
@@ -1154,13 +1281,10 @@ function sampleTrack(track, time) {
1154
1281
  const t0 = track.times[i], t1 = track.times[i + 1];
1155
1282
  const k = t1 > t0 ? (time - t0) / (t1 - t0) : 0;
1156
1283
  if (track.path === 'rotation') {
1157
- let a = [track.vals[i * 4], track.vals[i * 4 + 1], track.vals[i * 4 + 2], track.vals[i * 4 + 3]];
1158
- let b = [track.vals[i * 4 + 4], track.vals[i * 4 + 5], track.vals[i * 4 + 6], track.vals[i * 4 + 7]];
1159
- let d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
1160
- if (d < 0) { b = b.map((x) => -x); d = -d; }
1161
- const out = a.map((av, j) => av + (b[j] - av) * k);
1162
- const len = Math.hypot(out[0], out[1], out[2], out[3]) || 1;
1163
- return out.map((x) => x / len);
1284
+ const a = [track.vals[i * 4], track.vals[i * 4 + 1], track.vals[i * 4 + 2], track.vals[i * 4 + 3]];
1285
+ const b = [track.vals[i * 4 + 4], track.vals[i * 4 + 5], track.vals[i * 4 + 6], track.vals[i * 4 + 7]];
1286
+ // 球面插值:角速度恒定(原实现为 nlerp,长弧段会变速)
1287
+ return quatSlerp(a, b, k);
1164
1288
  }
1165
1289
  return [0, 1, 2].map((j) => track.vals[i * 3 + j] + (track.vals[(i + 1) * 3 + j] - track.vals[i * 3 + j]) * k);
1166
1290
  }
@@ -1253,7 +1377,7 @@ function b64ToU16(b64) { const u8 = b64ToU8(b64); return new Uint16Array(u8.buff
1253
1377
  function b64ToU32(b64) { const u8 = b64ToU8(b64); return new Uint32Array(u8.buffer, u8.byteOffset, u8.byteLength / 4); }
1254
1378
 
1255
1379
  /** 模型包 JSON → 与 parseGlb 同形的 asset(可直接喂 boneMatrices / 渲染)。 */
1256
- export function decodeModelPack(pack) {
1380
+ export function decodeModelPack(pack, actionClips) {
1257
1381
  const asset = {
1258
1382
  id: pack.id,
1259
1383
  label: pack.label,
@@ -1272,9 +1396,32 @@ export function decodeModelPack(pack) {
1272
1396
  rawHeight: pack.rawHeight,
1273
1397
  verts: pack.verts, tris: pack.tris,
1274
1398
  };
1399
+ // 动作库(内置成熟动作:three.js AnimationClip 转 tracks,base64 Float32)
1400
+ if (Array.isArray(actionClips)) {
1401
+ for (const c of actionClips) {
1402
+ if (!c || !c.name || !Array.isArray(c.tracks)) continue;
1403
+ // 只追加同名不存在的动作:内置片段(待机/行走/奔跑/潜行)保持原样,绝不被覆盖
1404
+ if (asset.animations.some((a) => a.name === c.name)) continue;
1405
+ asset.animations.push({
1406
+ name: c.name, duration: Number(c.duration) || 0, label: c.label || c.name, loop: !!c.loop,
1407
+ tracks: c.tracks.map((t) => ({ node: t.node, path: t.path, times: b64ToF32(t.times), vals: b64ToF32(t.vals) })),
1408
+ });
1409
+ }
1410
+ }
1275
1411
  asset.clipByName = (name) => asset.animations.find((a) => a.name === name) || null;
1276
1412
  return asset;
1277
1413
  }
1414
+ /** 解析动作库文件(clips/*.json,base64 tracks)→ decodeModelPack 第二参可直接用的数组。 */
1415
+ export function decodeActionClips(list) {
1416
+ if (!Array.isArray(list)) return [];
1417
+ return list.map((c) => ({
1418
+ name: String(c.name || ''),
1419
+ label: c.label || c.name || '',
1420
+ duration: Number(c.duration) || 0,
1421
+ loop: !!c.loop,
1422
+ tracks: (c.tracks || []).map((t) => ({ node: t.node, path: t.path, times: t.times, vals: t.vals })),
1423
+ }));
1424
+ }
1278
1425
 
1279
1426
  /**
1280
1427
  * CPU 蒙皮:算出某一时刻的顶点/法线(写进传入的复用数组,避免每帧分配)。
@@ -1445,7 +1592,212 @@ function solveDirsIntoLocal(asset, local, dirs) {
1445
1592
  }
1446
1593
  }
1447
1594
 
1448
- export function boneMatricesWithPose(asset, time, poseMap, dirs, baseEuler, rootY) {
1595
+ /* ============================================================
1596
+ * 约束与 IK(Phase C)
1597
+ * 约束:damped_track(骨骼 Y 轴指向目标)/ copy_rotation / limit_rotation
1598
+ * IK:两骨解析解(余弦定理 + 极向量),链 = [根骨, 中间骨, 末端骨]
1599
+ * 求值顺序对齐 Blender:FK 姿态 → 约束 → IK(constraint.cc / ikplugin 语义;算法自行实现)
1600
+ * ============================================================ */
1601
+ /** 四元数 → 欧拉(ZYX 顺序,与 eulerToQuat 的 qz*qy*qx 对应)。 */
1602
+ export function quatToEuler(q) {
1603
+ const x = Number(q && q[0]) || 0, y = Number(q && q[1]) || 0, z = Number(q && q[2]) || 0, w = Number(q && q[3]) || 1;
1604
+ const m00 = 1 - 2 * (y * y + z * z), m10 = 2 * (x * y + z * w), m20 = 2 * (x * z - y * w);
1605
+ const m21 = 2 * (y * z + x * w), m22 = 1 - 2 * (x * x + y * y);
1606
+ const beta = Math.asin(Math.max(-1, Math.min(1, -m20)));
1607
+ if (Math.abs(m20) < 0.9999999) {
1608
+ return [Math.atan2(m21, m22), beta, Math.atan2(m10, m00)];
1609
+ }
1610
+ // 万向锁兜底
1611
+ const m01 = 2 * (x * y - z * w), m11 = 1 - 2 * (x * x + z * z);
1612
+ return [Math.atan2(-m01, m11), beta, 0];
1613
+ }
1614
+ /** 罗德里格斯:绕单位轴 axis 旋转 v 角 ang。 */
1615
+ function rotateVecAxis(v, axis, ang) {
1616
+ const c = Math.cos(ang), s2 = Math.sin(ang);
1617
+ const d = v.x * axis.x + v.y * axis.y + v.z * axis.z;
1618
+ const cr = vCross(axis, v);
1619
+ return v3(
1620
+ v.x * c + cr.x * s2 + axis.x * d * (1 - c),
1621
+ v.y * c + cr.y * s2 + axis.y * d * (1 - c),
1622
+ v.z * c + cr.z * s2 + axis.z * d * (1 - c),
1623
+ );
1624
+ }
1625
+ /** 由 local 变换算世界矩阵表(与 boneMatricesWithPose 的 ② 同构)。 */
1626
+ function localWorldMatrices(asset, local) {
1627
+ const n = local.length;
1628
+ const worldM = new Array(n);
1629
+ const parentOf = (i) => (asset.parentOfAll ? asset.parentOfAll[i] : -1);
1630
+ const resolve = (i, guard) => {
1631
+ if (worldM[i]) return worldM[i];
1632
+ if (guard > 128) return directorMat4Identity();
1633
+ const Lp = local[i];
1634
+ const m = trsToMat4(Lp.t, Lp.r, Lp.s);
1635
+ const p = parentOf(i);
1636
+ worldM[i] = (p >= 0 && p !== i) ? directorMat4Multiply(resolve(p, guard + 1), m) : m;
1637
+ return worldM[i];
1638
+ };
1639
+ for (let i = 0; i < n; i++) resolve(i, 0);
1640
+ return worldM;
1641
+ }
1642
+ const m4pos = (m) => v3(m[12], m[13], m[14]);
1643
+ /** IK 目标点:target 支持 [x,y,z] / {x,y,z} / {keys:[{t,pos:[x,y,z]}]}。 */
1644
+ export function ikTargetPoint(spec, time) {
1645
+ const t = spec && spec.target;
1646
+ if (!t) return null;
1647
+ if (Array.isArray(t)) return v3(Number(t[0]) || 0, Number(t[1]) || 0, Number(t[2]) || 0);
1648
+ if (typeof t === 'object') {
1649
+ if (Array.isArray(t.keys) && t.keys.length) {
1650
+ const gx = evalKeyChannel(t.keys, (k) => (k.pos ? k.pos[0] : undefined), time);
1651
+ const gy = evalKeyChannel(t.keys, (k) => (k.pos ? k.pos[1] : undefined), time);
1652
+ const gz = evalKeyChannel(t.keys, (k) => (k.pos ? k.pos[2] : undefined), time);
1653
+ return v3(gx === null ? 0 : gx, gy === null ? 0 : gy, gz === null ? 0 : gz);
1654
+ }
1655
+ return v3(Number(t.x) || 0, Number(t.y) || 0, Number(t.z) || 0);
1656
+ }
1657
+ return null;
1658
+ }
1659
+ /**
1660
+ * 两骨解析 IK 的方向解:给定三关节世界位置与目标,返回 [根骨方向, 中间骨方向](世界单位向量)。
1661
+ * 复用引擎的方向求解器落地到 local 旋转(见 resolveIkAndConstraints)。
1662
+ */
1663
+ export function solveTwoBoneDirs(j0, j1, j2, target, pole) {
1664
+ const L1 = vLen(vSub(j1, j0)), L2 = vLen(vSub(j2, j1));
1665
+ const v = vSub(target, j0);
1666
+ const dRaw = vLen(v) || 1e-6;
1667
+ const d = Math.max(Math.abs(L1 - L2) + 1e-4, Math.min(L1 + L2 - 1e-4, dRaw));
1668
+ const dir = vMul(v, 1 / dRaw);
1669
+ let bend = pole ? vSub(pole, j0) : vSub(j1, j0);
1670
+ let axis = vCross(dir, bend);
1671
+ if (vLen(axis) < 1e-6) {
1672
+ axis = vCross(dir, v3(0, 1, 0));
1673
+ if (vLen(axis) < 1e-6) axis = vCross(dir, v3(1, 0, 0));
1674
+ }
1675
+ axis = vNorm(axis);
1676
+ const cos1 = Math.max(-1, Math.min(1, (L1 * L1 + d * d - L2 * L2) / (2 * L1 * d)));
1677
+ const a1 = Math.acos(cos1);
1678
+ // 绕 axis 旋转 dir 得到根骨方向;若与极向量方向相反则取负角(弯向极向量)
1679
+ let upperDir = rotateVecAxis(dir, axis, a1);
1680
+ const mid0 = vAdd(j0, vMul(upperDir, L1));
1681
+ if (pole && vDot(vSub(mid0, j0), vSub(pole, j0)) < 0) upperDir = rotateVecAxis(dir, axis, -a1);
1682
+ const mid = vAdd(j0, vMul(upperDir, L1));
1683
+ const endTarget = vAdd(j0, vMul(dir, d));
1684
+ const lowerDir = vLen(vSub(endTarget, mid)) < 1e-6 ? upperDir : vNorm(vSub(endTarget, mid));
1685
+ return [upperDir, lowerDir];
1686
+ }
1687
+ /** 应用约束 + IK 到 local(就地修改 local 旋转)。 */
1688
+ export function resolveIkAndConstraints(asset, local, opts, time) {
1689
+ if (!asset || !local || !opts) return;
1690
+ const bones = asset.bones || [];
1691
+ const t = Math.max(0, Number(time) || 0);
1692
+ const shortOf = (kw) => String(kw || '').replace(/^mixamorig:?/i, '');
1693
+ const parentOfNode = (i) => (asset.parentOfAll ? asset.parentOfAll[i] : -1);
1694
+ const childNodeOf = (bi) => {
1695
+ const cn = BONE_CHILD[shortOf(bones[bi].name)];
1696
+ if (!cn) return -1;
1697
+ const ci = bones.findIndex((x) => shortOf(x.name) === cn);
1698
+ return ci >= 0 ? bones[ci].nodeIdx : -1;
1699
+ };
1700
+ /**
1701
+ * 把某根骨骼转到「世界方向 targetDir」:qLocal = P⁻¹ · qWorld · P(P=父节点世界旋转),
1702
+ * 这样旋转轴心恒为该骨骼关节(不会像旧 dirs 路径那样绕原点转整条骨架)。
1703
+ */
1704
+ const aimBone = (worldMIn, bi, targetDir) => {
1705
+ const ni = bones[bi].nodeIdx;
1706
+ if (!(ni >= 0) || !local[ni]) return worldMIn;
1707
+ const want = vNorm(v3(Number(targetDir.x) || 0, Number(targetDir.y) || 0, Number(targetDir.z) || 0));
1708
+ if (vLen(want) < 1e-9) return worldMIn;
1709
+ const childNi = childNodeOf(bi);
1710
+ let wm = worldMIn;
1711
+ // 迭代修正:父级含缩放/非正交时,一次共轭会有几度系统残差;迭代 3 次收敛到 1e-6
1712
+ for (let iter = 0; iter < 3; iter++) {
1713
+ const bw = wm[ni];
1714
+ const head = v3(bw[12], bw[13], bw[14]);
1715
+ let cur;
1716
+ if (childNi >= 0 && wm[childNi]) {
1717
+ const cw = wm[childNi];
1718
+ cur = vNorm(v3(cw[12] - head.x, cw[13] - head.y, cw[14] - head.z));
1719
+ } else {
1720
+ cur = vNorm(v3(bw[4], bw[5], bw[6]));
1721
+ }
1722
+ if (vLen(cur) < 1e-9 || vDot(cur, want) > 0.9999999) break;
1723
+ const qW = quatFromTo([cur.x, cur.y, cur.z], [want.x, want.y, want.z]);
1724
+ const pn = parentOfNode(ni);
1725
+ const P = pn >= 0 && wm[pn] ? mat4RotationQuat(wm[pn]) : [0, 0, 0, 1];
1726
+ const qLocal = quatMul(quatConj(P), quatMul(qW, P));
1727
+ local[ni].r = quatMul(qLocal, local[ni].r);
1728
+ wm = localWorldMatrices(asset, local);
1729
+ }
1730
+ return wm;
1731
+ };
1732
+
1733
+ // ── ① 约束(copy / limit 先做,不需要世界矩阵;damped_track 需要世界矩阵)──
1734
+ let worldM = null;
1735
+ for (const c of Array.isArray(opts.constraints) ? opts.constraints : []) {
1736
+ if (!c || !c.bone) continue;
1737
+ const bi = matchBone(bones, c.bone);
1738
+ if (bi < 0) continue;
1739
+ const ni = bones[bi].nodeIdx;
1740
+ if (!(ni >= 0) || !local[ni]) continue;
1741
+ const type = String(c.type || '').toLowerCase();
1742
+ if (type === 'copy_rotation' || type === 'copyrotation') {
1743
+ const fromBi = matchBone(bones, c.from || c.source || '');
1744
+ if (fromBi < 0) continue;
1745
+ const fni = bones[fromBi].nodeIdx;
1746
+ if (!(fni >= 0) || !local[fni]) continue;
1747
+ const mix = Math.max(0, Math.min(1, Number(c.mix === undefined ? 1 : c.mix)));
1748
+ local[ni].r = mix >= 1 ? local[fni].r.slice() : quatSlerp(local[ni].r, local[fni].r, mix);
1749
+ } else if (type === 'limit_rotation' || type === 'limitrotation') {
1750
+ const e = quatToEuler(local[ni].r);
1751
+ const mn = Array.isArray(c.min) ? c.min : [-Math.PI, -Math.PI, -Math.PI];
1752
+ const mx = Array.isArray(c.max) ? c.max : [Math.PI, Math.PI, Math.PI];
1753
+ for (let k = 0; k < 3; k++) {
1754
+ const lo = Number(mn[k]); const hi = Number(mx[k]);
1755
+ if (Number.isFinite(lo)) e[k] = Math.max(lo, e[k]);
1756
+ if (Number.isFinite(hi)) e[k] = Math.min(hi, e[k]);
1757
+ }
1758
+ local[ni].r = eulerToQuat(e[0], e[1], e[2]);
1759
+ } else if (type === 'damped_track' || type === 'dampedtrack' || type === 'track') {
1760
+ const tp = ikTargetPoint(c, t);
1761
+ if (!tp) continue;
1762
+ worldM = worldM || localWorldMatrices(asset, local);
1763
+ const head = v3(worldM[ni][12], worldM[ni][13], worldM[ni][14]);
1764
+ const dir = vSub(tp, head);
1765
+ if (vLen(dir) < 1e-6) continue;
1766
+ worldM = aimBone(worldM, bi, vNorm(dir));
1767
+ }
1768
+ }
1769
+
1770
+ // ── ② IK:两骨解析(链 = [根骨, 中间骨, 末端骨])──
1771
+ const chains = Array.isArray(opts.ik) ? opts.ik : [];
1772
+ for (const spec of chains) {
1773
+ const names = (spec && Array.isArray(spec.chain) ? spec.chain : []).map(String);
1774
+ if (names.length < 3) continue;
1775
+ const bis = names.map((n) => matchBone(bones, n));
1776
+ if (bis.some((x) => x < 0)) continue;
1777
+ const nodes = bis.map((bi) => bones[bi].nodeIdx);
1778
+ if (nodes.some((n2) => !(n2 >= 0))) continue;
1779
+ const target = ikTargetPoint(spec, t);
1780
+ if (!target) continue;
1781
+ worldM = worldM || localWorldMatrices(asset, local);
1782
+ const j0 = v3(worldM[nodes[0]][12], worldM[nodes[0]][13], worldM[nodes[0]][14]);
1783
+ const j1 = v3(worldM[nodes[1]][12], worldM[nodes[1]][13], worldM[nodes[1]][14]);
1784
+ const j2 = v3(worldM[nodes[2]][12], worldM[nodes[2]][13], worldM[nodes[2]][14]);
1785
+ const pole = spec.pole
1786
+ ? (Array.isArray(spec.pole) ? v3(Number(spec.pole[0]) || 0, Number(spec.pole[1]) || 0, Number(spec.pole[2]) || 0) : ikTargetPoint({ target: spec.pole }, t))
1787
+ : null;
1788
+ const solved = solveTwoBoneDirs(j0, j1, j2, target, pole || null);
1789
+ worldM = aimBone(worldM, bis[0], solved[0]);
1790
+ worldM = aimBone(worldM, bis[1], solved[1]);
1791
+ }
1792
+ }
1793
+
1794
+ /**
1795
+ * 仅应用姿态/约束/IK 到 local(不计算世界矩阵/蒙皮矩阵)。
1796
+ * 测试与工具用它拿「节点空间」结果;预览/导出走的 boneMatricesWithPose 内部同样调用它。
1797
+ */
1798
+ export function applyBonePoseToLocal(asset, time, poseMap, dirs, baseEuler, rootY, opts) {
1799
+ if (!asset || !asset.bones || !asset.nodes) return null;
1800
+
1449
1801
  if (!asset || !asset.bones || !asset.nodes) return null;
1450
1802
  const anim = asset._anim;
1451
1803
  const t = animLoopTime(asset, time);
@@ -1488,11 +1840,193 @@ export function boneMatricesWithPose(asset, time, poseMap, dirs, baseEuler, root
1488
1840
  const hi = (asset.bones || []).findIndex((x) => String(x.name || "").replace(/^mixamorig:?/i, "") === "Hips");
1489
1841
  if (hi >= 0) {
1490
1842
  const ni3 = asset.bones[hi].nodeIdx;
1491
- if (ni3 !== undefined && local[ni3] && local[ni3].t) local[ni3].t[1] += rootY;
1843
+ if (ni3 !== undefined && local[ni3] && local[ni3].t) {
1844
+ // rootY 以「米」为单位,而骨骼节点位移是模型本地位(xbot 为厘米级 ~100×)
1845
+ const kRoot = 100;
1846
+ local[ni3].t[1] += rootY * kRoot;
1847
+ }
1848
+ }
1849
+ }
1850
+ // ①b2 动作层(BVH 重定向后的 poseKeys / NLA-lite):以姿态增量叠加到当前姿势
1851
+ if (opts && Array.isArray(opts.clips) && opts.clips.length) {
1852
+ const cpm = sampleClips(asset, opts.clips, time || 0);
1853
+ for (const [kk, rot] of Object.entries(cpm || {})) {
1854
+ const bi2 = Number(kk);
1855
+ const bone2 = asset.bones[bi2];
1856
+ if (!bone2) continue;
1857
+ const ni2 = bone2.nodeIdx;
1858
+ if (!(ni2 >= 0) || !local[ni2]) continue;
1859
+ const q2 = rot.length === 4 ? rot : eulerToQuat(Number(rot[0]) || 0, Number(rot[1]) || 0, Number(rot[2]) || 0);
1860
+ local[ni2].r = quatMul(local[ni2].r, q2);
1492
1861
  }
1493
1862
  }
1494
1863
  // ①c 方向约束(基于「动画 + 基底」后的当前姿势求解,因此永远不会被动画带偏)
1495
1864
  if (dirs && dirs.length) solveDirsIntoLocal(asset, local, dirs);
1865
+ // ①d 约束 + IK(FK 之后;对齐 Blender 求值顺序:姿势 → 约束 → IK)
1866
+ if (opts && (Array.isArray(opts.ik) || Array.isArray(opts.constraints))) resolveIkAndConstraints(asset, local, opts, time || 0);
1867
+
1868
+ return local;
1869
+ }
1870
+
1871
+ /** 节点空间世界矩阵(与 boneMatrices 同一套 TRS 组合;供 IK/测试/工具使用)。 */
1872
+ export { localWorldMatrices };
1873
+
1874
+ /* ============================================================
1875
+ * 动捕数据接入(Phase D):BVH 解析 → rest-delta 重定向 → 动作层混合
1876
+ * 参考 Blender 的骨骼 rest/pose 矩阵语义(arm_mat / pose_mat)与 NLA strip
1877
+ * 混合(replace/add/multiply + influence);算法自行实现(GPL 兼容性)。
1878
+ * ============================================================ */
1879
+ const normBone = (n) => String(n || '').toLowerCase().replace(/^mixamorig:?/i, '').replace(/[^a-z0-9]/g, '');
1880
+ /** 解析 BVH 文本 → { joints:[{name,parent,offset,channels,end}], frames:number[][], frameTime }。 */
1881
+ export function parseBVH(text) {
1882
+ const src = String(text || '');
1883
+ const lines = src.split(/\r?\n/);
1884
+ const joints = [];
1885
+ const stack = [];
1886
+ let frameTime = 1 / 30;
1887
+ const frames = [];
1888
+ let mode = 'hierarchy';
1889
+ let pendingFrames = 0;
1890
+ for (let li = 0; li < lines.length; li++) {
1891
+ const line = lines[li].trim();
1892
+ if (!line) continue;
1893
+ if (/^MOTION$/i.test(line)) { mode = 'motion'; continue; }
1894
+ if (mode === 'motion') {
1895
+ const fm = /^Frames:\s*(\d+)/i.exec(line);
1896
+ if (fm) { pendingFrames = Number(fm[1]); continue; }
1897
+ const tm = /^Frame Time:\s*([\d.eE+-]+)/i.exec(line);
1898
+ if (tm) { frameTime = Number(tm[1]) || frameTime; continue; }
1899
+ const vals = line.split(/[\s,]+/).filter((x) => x !== '').map(Number).filter((x) => Number.isFinite(x));
1900
+ if (vals.length) frames.push(vals);
1901
+ continue;
1902
+ }
1903
+ const head = /^(ROOT|JOINT)\s+(\S+)/i.exec(line);
1904
+ if (head) {
1905
+ const parent = stack.length ? stack[stack.length - 1] : -1;
1906
+ const idx = joints.length;
1907
+ joints.push({ name: head[2], parent, offset: [0, 0, 0], channels: [], end: false });
1908
+ stack.push(idx);
1909
+ continue;
1910
+ }
1911
+ if (/^End Site/i.test(line)) {
1912
+ const parent = stack.length ? stack[stack.length - 1] : -1;
1913
+ joints.push({ name: 'End_' + joints.length, parent, offset: [0, 0, 0], channels: [], end: true });
1914
+ stack.push(joints.length - 1);
1915
+ continue;
1916
+ }
1917
+ if (/^OFFSET/i.test(line)) {
1918
+ const p = line.split(/\s+/);
1919
+ const cur = joints[stack[stack.length - 1]];
1920
+ if (cur) cur.offset = [Number(p[1]) || 0, Number(p[2]) || 0, Number(p[3]) || 0];
1921
+ continue;
1922
+ }
1923
+ if (/^CHANNELS/i.test(line)) {
1924
+ const p = line.split(/\s+/);
1925
+ const cur = joints[stack[stack.length - 1]];
1926
+ if (cur) cur.channels = p.slice(2);
1927
+ continue;
1928
+ }
1929
+ if (line === '}') { stack.pop(); continue; }
1930
+ }
1931
+ const totalFrames = pendingFrames || frames.length;
1932
+ return { joints, frames: frames.slice(0, totalFrames || frames.length), frameTime };
1933
+ }
1934
+ /** BVH 关节名 → 资产骨骼索引(按规范化名精确匹配;找不到跳过)。 */
1935
+ export function mapBVHToBones(asset, bvh) {
1936
+ const bones = (asset && asset.bones) || [];
1937
+ const byName = new Map();
1938
+ bones.forEach((b, bi) => byName.set(normBone(b.name), bi));
1939
+ const map = [];
1940
+ for (const j of (bvh && bvh.joints) || []) map.push(byName.has(normBone(j.name)) ? byName.get(normBone(j.name)) : -1);
1941
+ return map;
1942
+ }
1943
+ /** 单帧关节局部旋转(按通道顺序,第一通道最外层)。 */
1944
+ export function bvhJointLocalQuat(joint, values, offset) {
1945
+ let q = [0, 0, 0, 1];
1946
+ let k = offset;
1947
+ for (const ch of (joint && joint.channels) || []) {
1948
+ const rad = (Number(values[k]) || 0) * Math.PI / 180;
1949
+ let qi;
1950
+ if (/^Xrotation$/i.test(ch)) qi = [Math.sin(rad / 2), 0, 0, Math.cos(rad / 2)];
1951
+ else if (/^Yrotation$/i.test(ch)) qi = [0, Math.sin(rad / 2), 0, Math.cos(rad / 2)];
1952
+ else if (/^Zrotation$/i.test(ch)) qi = [0, 0, Math.sin(rad / 2), Math.cos(rad / 2)];
1953
+ else { k++; continue; }
1954
+ q = quatMul(q, qi);
1955
+ k++;
1956
+ }
1957
+ return q;
1958
+ }
1959
+ /**
1960
+ * BVH → 目标骨架的 poseKeys(rest-delta 重定向):
1961
+ * q_target = q_rest_local · q_src_local(xbot 的 rest 局部旋转基本为单位四元数,故近似为局部旋转叠加)
1962
+ * 返回 { poseKeys:[{t,bonePose, ease}], duration, frameTime }。
1963
+ */
1964
+ export function retargetBVH(asset, bvh, opts) {
1965
+ const o = opts || {};
1966
+ if (!asset || !bvh || !Array.isArray(bvh.frames) || !bvh.frames.length) return { poseKeys: [], duration: 0, frameTime: bvh ? bvh.frameTime : 0 };
1967
+ const map = mapBVHToBones(asset, bvh);
1968
+ const channelsBefore = [];
1969
+ {
1970
+ let acc = 0;
1971
+ for (const j of bvh.joints) { channelsBefore.push(acc); acc += (j.channels || []).length; }
1972
+ }
1973
+ const poseKeys = [];
1974
+ const frameStep = o.sampleStep ? Math.max(1, Math.round(o.sampleStep)) : 1;
1975
+ for (let f = 0; f < bvh.frames.length; f += frameStep) {
1976
+ const values = bvh.frames[f];
1977
+ const bonePose = {};
1978
+ let any = false;
1979
+ for (let ji = 0; ji < bvh.joints.length; ji++) {
1980
+ const bi = map[ji];
1981
+ if (bi < 0) continue;
1982
+ const qSrc = bvhJointLocalQuat(bvh.joints[ji], values, channelsBefore[ji]);
1983
+ const bone = asset.bones[bi];
1984
+ const qRest = bone && bone.r && bone.r.length === 4 ? bone.r : [0, 0, 0, 1];
1985
+ bonePose[bi] = quatMul([Number(qRest[0]) || 0, Number(qRest[1]) || 0, Number(qRest[2]) || 0, Number(qRest[3]) || 1], qSrc);
1986
+ any = true;
1987
+ }
1988
+ if (any) poseKeys.push({ t: f * (Number(bvh.frameTime) || 1 / 30), bonePose });
1989
+ }
1990
+ return {
1991
+ poseKeys,
1992
+ duration: bvh.frames.length * (Number(bvh.frameTime) || 1 / 30),
1993
+ frameTime: Number(bvh.frameTime) || 1 / 30,
1994
+ };
1995
+ }
1996
+ /**
1997
+ * 动作层混合(NLA-lite):clip = { keys:[{t,bonePose}], t0, t1, loop, blend:'replace'|'add'|'multiply', influence }
1998
+ * 返回 poseMap(boneIndex → 四元数),供 skinModel 以「姿态增量」叠加。
1999
+ */
2000
+ export function sampleClips(asset, clips, time) {
2001
+ const out = {};
2002
+ const t = Math.max(0, Number(time) || 0);
2003
+ for (const clip of Array.isArray(clips) ? clips : []) {
2004
+ const keys = Array.isArray(clip && clip.keys) ? clip.keys : [];
2005
+ if (!keys.length) continue;
2006
+ const t0 = Number(clip.t0) || 0;
2007
+ const lastT = Number(keys[keys.length - 1].t) || 0;
2008
+ const t1 = Number(clip.t1) || lastT;
2009
+ const dur = Math.max(1e-6, t1 - t0);
2010
+ let tl = t - t0;
2011
+ if (clip.loop) tl = ((tl % dur) + dur) % dur;
2012
+ tl = Math.max(0, Math.min(dur, tl));
2013
+ const pm = interpolatePoseKeys(keys, tl);
2014
+ if (!pm) continue;
2015
+ const influence = Math.max(0, Math.min(1, Number(clip.influence === undefined ? 1 : clip.influence)));
2016
+ const mode = String(clip.blend || 'replace').toLowerCase();
2017
+ for (const [k, rot] of Object.entries(pm)) {
2018
+ const q = rot.length === 4 ? rot.slice() : eulerToQuat(Number(rot[0]) || 0, Number(rot[1]) || 0, Number(rot[2]) || 0);
2019
+ const base = out[k] || [0, 0, 0, 1];
2020
+ if (mode === 'replace') out[k] = quatSlerp(base, q, influence);
2021
+ else out[k] = quatMul(base, quatSlerp([0, 0, 0, 1], q, influence));
2022
+ }
2023
+ }
2024
+ return out;
2025
+ }
2026
+
2027
+ export function boneMatricesWithPose(asset, time, poseMap, dirs, baseEuler, rootY, opts) {
2028
+ const local = applyBonePoseToLocal(asset, time, poseMap, dirs, baseEuler, rootY, opts);
2029
+ if (!local) return null;
1496
2030
  // ② 自顶向下世界矩阵
1497
2031
  const n = local.length;
1498
2032
  const worldM = new Array(n);
@@ -1546,8 +2080,8 @@ export function poseBlendAt(posedefs, poseAnim, time) {
1546
2080
  for (let i = 0; i < ks.length - 1; i++) if (t >= ks[i].t && t <= ks[i + 1].t) { a = ks[i]; b = ks[i + 1]; break; }
1547
2081
  const span = b.t - a.t;
1548
2082
  const k = span > 1e-6 ? (t - a.t) / span : 0;
1549
- // smoothstep 缓动:起止更自然
1550
- const e = k * k * (3 - 2 * k);
2083
+ // 缓动:默认 smoothstep(起止更自然);可按帧用 ease 覆盖
2084
+ const e = easeFactor(b.ease || a.ease || 'auto', k);
1551
2085
  const sa = find(a.poseId), sb = find(b.poseId);
1552
2086
  const out = [];
1553
2087
  if (sa && e < 0.999) out.push({ base: sa.base || null, dirs: sa.dirs || [], rootY: Number(sa.rootY) || 0, w: 1 - e });
@@ -1555,8 +2089,8 @@ export function poseBlendAt(posedefs, poseAnim, time) {
1555
2089
  return out.length ? out : null;
1556
2090
  }
1557
2091
 
1558
- /** 四元数球面插值(a→b,t∈[0,1]) */
1559
- function quatSlerp(a, b, t) {
2092
+ /** 四元数球面插值(a→b,t∈[0,1],最短弧) */
2093
+ export function quatSlerp(a, b, t) {
1560
2094
  let ax = a[0], ay = a[1], az = a[2], aw = a[3];
1561
2095
  let bx = b[0], by = b[1], bz = b[2], bw = b[3];
1562
2096
  let d = ax * bx + ay * by + az * bz + aw * bw;
@@ -1659,6 +2193,86 @@ export function boneMatricesWithPoseMix(asset, time, list) {
1659
2193
  return { mats: out };
1660
2194
  }
1661
2195
 
2196
+ /* ============================================================
2197
+ * 双四元数蒙皮(DQS):解决线性混合蒙皮(LBS)在大角度关节处的塌陷/糖纸
2198
+ * 算法对照 Blender 源码(只借鉴算法,不复制 GPL 代码;本插件 Apache-2.0):
2199
+ * armature_update.cc#mat4_to_dquat / armature_deform.cc 的 deform_dual_quat 混合
2200
+ * ============================================================ */
2201
+ function quatConj(q) { return [-q[0], -q[1], -q[2], q[3]]; }
2202
+ function quatRotateVec(q, v) {
2203
+ const t = quatMul(quatMul(q, [v[0], v[1], v[2], 0]), quatConj(q));
2204
+ return [t[0], t[1], t[2]];
2205
+ }
2206
+ /** 列主序 3x3(取自 mat4 的旋转列)→ 单位四元数 [x,y,z,w]。 */
2207
+ export function mat4RotationQuat(m) {
2208
+ const m00 = m[0], m01 = m[4], m02 = m[8];
2209
+ const m10 = m[1], m11 = m[5], m12 = m[9];
2210
+ const m20 = m[2], m21 = m[6], m22 = m[10];
2211
+ const tr = m00 + m11 + m22;
2212
+ let x, y, z, w;
2213
+ if (tr > 0) {
2214
+ const q = Math.sqrt(tr + 1) * 2;
2215
+ w = 0.25 * q; x = (m21 - m12) / q; y = (m02 - m20) / q; z = (m10 - m01) / q;
2216
+ } else if (m00 > m11 && m00 > m22) {
2217
+ const q = Math.sqrt(1 + m00 - m11 - m22) * 2;
2218
+ w = (m21 - m12) / q; x = 0.25 * q; y = (m01 + m10) / q; z = (m02 + m20) / q;
2219
+ } else if (m11 > m22) {
2220
+ const q = Math.sqrt(1 + m11 - m00 - m22) * 2;
2221
+ w = (m02 - m20) / q; x = (m01 + m10) / q; y = 0.25 * q; z = (m12 + m21) / q;
2222
+ } else {
2223
+ const q = Math.sqrt(1 + m22 - m00 - m11) * 2;
2224
+ w = (m10 - m01) / q; x = (m02 + m20) / q; y = (m12 + m21) / q; z = 0.25 * q;
2225
+ }
2226
+ const l = Math.hypot(x, y, z, w) || 1;
2227
+ return [x / l, y / l, z / l, w / l];
2228
+ }
2229
+ /** mat4 → 单位双四元数 { r:[x,y,z,w], d:[x,y,z,w] },d = 0.5·t·r。 */
2230
+ export function mat4ToDualQuat(m) {
2231
+ const r = mat4RotationQuat(m);
2232
+ const t = [Number(m[12]) || 0, Number(m[13]) || 0, Number(m[14]) || 0, 0];
2233
+ const d = quatMul(t, r).map((v) => 0.5 * v);
2234
+ return { r, d };
2235
+ }
2236
+ /** 双四元数作用到点:p' = R·p·R* + 2·(D·R*).xyz。 */
2237
+ export function dqTransformPoint(R, D, p) {
2238
+ const rot = quatRotateVec(R, [p[0], p[1], p[2]]);
2239
+ const dr = quatMul(D, quatConj(R));
2240
+ return [rot[0] + 2 * dr[0], rot[1] + 2 * dr[1], rot[2] + 2 * dr[2]];
2241
+ }
2242
+ /** 单点蒙皮(测试/工具用):mode='lbs'|'dqs'。 */
2243
+ export function skinPointWithMats(mats, joints, weights, p, nb, mode) {
2244
+ const dqs = String(mode || 'lbs').toLowerCase() === 'dqs';
2245
+ if (!dqs) {
2246
+ let x = 0, y = 0, z = 0;
2247
+ for (let k = 0; k < 4; k++) {
2248
+ const w = Number(weights[k]) || 0, j = Number(joints[k]);
2249
+ if (!w || !(j >= 0) || j >= nb) continue;
2250
+ const o = j * 16;
2251
+ x += w * (mats[o] * p[0] + mats[o + 4] * p[1] + mats[o + 8] * p[2] + mats[o + 12]);
2252
+ y += w * (mats[o + 1] * p[0] + mats[o + 5] * p[1] + mats[o + 9] * p[2] + mats[o + 13]);
2253
+ z += w * (mats[o + 2] * p[0] + mats[o + 6] * p[1] + mats[o + 10] * p[2] + mats[o + 14]);
2254
+ }
2255
+ return [x, y, z];
2256
+ }
2257
+ let R = [0, 0, 0, 0], D = [0, 0, 0, 0], ref = null, used = 0;
2258
+ for (let k = 0; k < 4; k++) {
2259
+ const w = Number(weights[k]) || 0, j = Number(joints[k]);
2260
+ if (!w || !(j >= 0) || j >= nb) continue;
2261
+ const dq = mat4ToDualQuat(mats.subarray ? mats.subarray(j * 16, j * 16 + 16) : mats.slice(j * 16, j * 16 + 16));
2262
+ let r = dq.r, d = dq.d;
2263
+ if (!ref) ref = r;
2264
+ else if (r[0] * ref[0] + r[1] * ref[1] + r[2] * ref[2] + r[3] * ref[3] < 0) {
2265
+ r = r.map((v) => -v); d = d.map((v) => -v);
2266
+ }
2267
+ for (let q = 0; q < 4; q++) { R[q] += w * r[q]; D[q] += w * d[q]; }
2268
+ used++;
2269
+ }
2270
+ if (!used) return [p[0], p[1], p[2]];
2271
+ const rl = Math.hypot(R[0], R[1], R[2], R[3]) || 1;
2272
+ R = R.map((v) => v / rl); D = D.map((v) => v / rl);
2273
+ return dqTransformPoint(R, D, p);
2274
+ }
2275
+
1662
2276
  export function skinModel(asset, clipName, time, outPos, outNrm, shape) {
1663
2277
  const clip = clipName ? asset.clipByName(clipName) : null;
1664
2278
  asset._anim = clip;
@@ -1669,15 +2283,24 @@ export function skinModel(asset, clipName, time, outPos, outNrm, shape) {
1669
2283
  const poseBase = spec ? (spec.base || null) : null;
1670
2284
  const poseRootY = spec ? (Number(spec.rootY) || 0) : (Number(shape && shape.rootY) || 0);
1671
2285
  const poseBlend = shape && shape.poseBlend && shape.poseBlend.length ? shape.poseBlend : null;
1672
- const wantPose = !!(poseMap || (poseDirs && poseDirs.length) || poseBase || poseBlend);
2286
+ const wantPose = !!(poseMap || (poseDirs && poseDirs.length) || poseBase || poseBlend || (shape && (shape.ik || shape.constraints || shape.clips)));
1673
2287
  const bm = asset.bones && asset.bones.length
1674
2288
  ? (poseBlend ? boneMatricesWithPoseMix(asset, time || 0, poseBlend)
1675
- : (wantPose ? boneMatricesWithPose(asset, time || 0, poseMap, poseDirs, poseBase, poseRootY) : boneMatrices(asset, time || 0)))
2289
+ : (wantPose ? boneMatricesWithPose(asset, time || 0, poseMap, poseDirs, poseBase, poseRootY, shape && (shape.ik || shape.constraints || shape.clips) ? { ik: shape.ik, constraints: shape.constraints, clips: shape.clips } : null)
2290
+ : boneMatrices(asset, time || 0)))
1676
2291
  : null;
1677
2292
  const P = asset.positions, N = asset.normals, J = asset.joints, W = asset.weights, nb = asset.bones ? asset.bones.length : 0;
1678
2293
  const pos = outPos && outPos.length === P.length ? outPos : new Float32Array(P.length);
1679
2294
  const nrm = outNrm && outNrm.length === P.length ? outNrm : new Float32Array(P.length);
1680
2295
  const nv = P.length / 3;
2296
+ const modeRaw = String((shape && shape.skinning) || 'lbs').toLowerCase();
2297
+ const useDQS = modeRaw === 'dqs' || modeRaw === 'auto';
2298
+ // 每帧一次:把骨骼蒙皮矩阵转成双四元数(不是每顶点)
2299
+ let dqsBones = null;
2300
+ if (useDQS && bm && bm.mats && nb > 0) {
2301
+ dqsBones = new Array(nb);
2302
+ for (let j = 0; j < nb; j++) dqsBones[j] = mat4ToDualQuat(bm.mats.subarray(j * 16, j * 16 + 16));
2303
+ }
1681
2304
  for (let v = 0; v < nv; v++) {
1682
2305
  let x = 0, y = 0, z = 0, nx = 0, ny = 0, nz = 0;
1683
2306
  if (!bm || !J || !W) {
@@ -1685,6 +2308,43 @@ export function skinModel(asset, clipName, time, outPos, outNrm, shape) {
1685
2308
  nrm[v * 3] = N[v * 3]; nrm[v * 3 + 1] = N[v * 3 + 1]; nrm[v * 3 + 2] = N[v * 3 + 2];
1686
2309
  continue;
1687
2310
  }
2311
+ if (dqsBones) {
2312
+ // 双四元数线性混合(DLB):半球对齐后加权求和,再归一化
2313
+ let R = [0, 0, 0, 0], D = [0, 0, 0, 0], ref = null, used = 0;
2314
+ for (let k = 0; k < 4; k++) {
2315
+ const w = W[v * 4 + k];
2316
+ const j = J[v * 4 + k];
2317
+ if (!w || j >= nb) continue;
2318
+ const dq = dqsBones[j];
2319
+ let rr = dq.r, dd = dq.d;
2320
+ if (!ref) ref = rr;
2321
+ else if (rr[0] * ref[0] + rr[1] * ref[1] + rr[2] * ref[2] + rr[3] * ref[3] < 0) {
2322
+ rr = [-rr[0], -rr[1], -rr[2], -rr[3]];
2323
+ dd = [-dd[0], -dd[1], -dd[2], -dd[3]];
2324
+ }
2325
+ R[0] += w * rr[0]; R[1] += w * rr[1]; R[2] += w * rr[2]; R[3] += w * rr[3];
2326
+ D[0] += w * dd[0]; D[1] += w * dd[1]; D[2] += w * dd[2]; D[3] += w * dd[3];
2327
+ used++;
2328
+ }
2329
+ if (!used) {
2330
+ pos[v * 3] = P[v * 3]; pos[v * 3 + 1] = P[v * 3 + 1]; pos[v * 3 + 2] = P[v * 3 + 2];
2331
+ nrm[v * 3] = N[v * 3]; nrm[v * 3 + 1] = N[v * 3 + 1]; nrm[v * 3 + 2] = N[v * 3 + 2];
2332
+ continue;
2333
+ }
2334
+ const rl = Math.hypot(R[0], R[1], R[2], R[3]) || 1;
2335
+ R = [R[0] / rl, R[1] / rl, R[2] / rl, R[3] / rl];
2336
+ D = [D[0] / rl, D[1] / rl, D[2] / rl, D[3] / rl];
2337
+ // 与 skinPointWithMats 完全同源:p' = R·p·R* + 2·(D·R*).xyz
2338
+ const p2 = quatRotateVec(R, [P[v * 3], P[v * 3 + 1], P[v * 3 + 2]]);
2339
+ const dr = quatMul(D, quatConj(R));
2340
+ pos[v * 3] = p2[0] + 2 * dr[0];
2341
+ pos[v * 3 + 1] = p2[1] + 2 * dr[1];
2342
+ pos[v * 3 + 2] = p2[2] + 2 * dr[2];
2343
+ const n2 = quatRotateVec(R, [N[v * 3], N[v * 3 + 1], N[v * 3 + 2]]);
2344
+ const nl = Math.hypot(n2[0], n2[1], n2[2]) || 1;
2345
+ nrm[v * 3] = n2[0] / nl; nrm[v * 3 + 1] = n2[1] / nl; nrm[v * 3 + 2] = n2[2] / nl;
2346
+ continue;
2347
+ }
1688
2348
  for (let k = 0; k < 4; k++) {
1689
2349
  const w = W[v * 4 + k];
1690
2350
  const j = J[v * 4 + k];
@@ -1843,6 +2503,120 @@ export function removeShot(scene, index) {
1843
2503
  * ============================================================ */
1844
2504
 
1845
2505
  /** 姿态关键帧插值:返回该时刻的 bonePose(无关键帧返回 null)。 */
2506
+ /* ============================================================
2507
+ * 动画曲线核心(四元数 / 缓动表 / 三次贝塞尔 / 通道求值)
2508
+ * 算法对照 Blender 源码(只借鉴算法,不复制 GPL 代码;本插件 Apache-2.0):
2509
+ * - blenkernel/intern/fcurve.cc#fcurve_eval_keyframes_interpolate(BEZT_IPO_BEZ 三次贝塞尔)
2510
+ * - animrig/intern/fcurve.cc 的 auto handle 语义(HD_AUTO / HD_AUTO_ANIM)
2511
+ * - blenlib 的四元数插值语义(最短弧、归一化)
2512
+ * ============================================================ */
2513
+ export function quatNormalize(q) {
2514
+ const l = Math.hypot(Number(q[0]) || 0, Number(q[1]) || 0, Number(q[2]) || 0, Number(q[3]) || 0) || 1;
2515
+ return [q[0] / l, q[1] / l, q[2] / l, q[3] / l];
2516
+ }
2517
+ /** 缓动表(in 方向;out/inout 由 easeFactor 组合)。 */
2518
+ const EASE_IN_FN = {
2519
+ sine: (t) => 1 - Math.cos((t * Math.PI) / 2),
2520
+ quad: (t) => t * t,
2521
+ cubic: (t) => t * t * t,
2522
+ quart: (t) => t * t * t * t,
2523
+ quint: (t) => t * t * t * t * t,
2524
+ expo: (t) => (t <= 0 ? 0 : Math.pow(2, 10 * t - 10)),
2525
+ circ: (t) => 1 - Math.sqrt(Math.max(0, 1 - t * t)),
2526
+ back: (t) => { const c = 1.70158; return t * t * ((c + 1) * t - c); },
2527
+ elastic: (t) => {
2528
+ if (t <= 0) return 0;
2529
+ if (t >= 1) return 1;
2530
+ return -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * ((2 * Math.PI) / 3));
2531
+ },
2532
+ bounce: (t) => {
2533
+ if (t < 1 / 2.75) return 7.5625 * t * t;
2534
+ if (t < 2 / 2.75) { const u = t - 1.5 / 2.75; return 7.5625 * u * u + 0.75; }
2535
+ if (t < 2.5 / 2.75) { const u = t - 2.25 / 2.75; return 7.5625 * u * u + 0.9375; }
2536
+ const u = t - 2.625 / 2.75;
2537
+ return 7.5625 * u * u + 0.984375;
2538
+ },
2539
+ };
2540
+ /** 缓动:'auto'(smoothstep)/ 'linear' / 'constant' / '<曲线>_in|_out|_in_out'。 */
2541
+ export function easeFactor(kind, x) {
2542
+ const k = Math.max(0, Math.min(1, Number(x) || 0));
2543
+ const id = String(kind == null ? 'auto' : kind).toLowerCase().replace(/[\s-]+/g, '_');
2544
+ if (id === 'constant') return 0;
2545
+ if (id === 'linear') return k;
2546
+ if (id === 'auto' || id === 'smooth' || id === 'smoothstep' || id === 'bezier') return k * k * (3 - 2 * k);
2547
+ const m = /^(sine|quad|cubic|quart|quint|expo|circ|back|elastic|bounce)(?:_?(in_out|in|out))?$/.exec(id);
2548
+ if (m) {
2549
+ const fn = EASE_IN_FN[m[1]];
2550
+ const dir = m[2] || 'in_out';
2551
+ if (dir === 'in') return fn(k);
2552
+ if (dir === 'out') return 1 - fn(1 - k);
2553
+ return k < 0.5 ? fn(2 * k) / 2 : 1 - fn(2 - 2 * k) / 2;
2554
+ }
2555
+ return k * k * (3 - 2 * k);
2556
+ }
2557
+ /** 一维三次贝塞尔。 */
2558
+ function bez1(p0, p1, p2, p3, t) {
2559
+ const u = 1 - t;
2560
+ return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
2561
+ }
2562
+ /** 由 x 反解贝塞尔参数 t(x 单调;手柄已限幅)。二分法稳定且精度足够。 */
2563
+ function bezierSolveT(x0, x1, x2, x3, x) {
2564
+ if (x <= x0) return 0;
2565
+ if (x >= x3) return 1;
2566
+ let lo = 0, hi = 1;
2567
+ for (let i = 0; i < 48; i++) {
2568
+ const mid = (lo + hi) / 2;
2569
+ if (bez1(x0, x1, x2, x3, mid) < x) lo = mid; else hi = mid;
2570
+ }
2571
+ return (lo + hi) / 2;
2572
+ }
2573
+ /** 自动切线(auto-clamped 语义):极值点取水平切线,其余用中心差分并限幅。 */
2574
+ function autoHandle(keys, i, val) {
2575
+ const k = keys[i];
2576
+ const prev = keys[i - 1], next = keys[i + 1];
2577
+ const dtNext = next ? Math.max(1e-6, next.t - k.t) : 1e-6;
2578
+ if (!prev || !next) return { dx: 0, dy: 0 }; // 端点:水平切线(Blender 端点同义)
2579
+ const v0 = val(prev), v1 = val(k), v2 = val(next);
2580
+ const isExtremum = (v1 >= v0 && v1 >= v2) || (v1 <= v0 && v1 <= v2);
2581
+ const slope = isExtremum ? 0 : (v2 - v0) / Math.max(1e-6, next.t - prev.t);
2582
+ const dx = Math.min(dtNext / 3, (k.t - prev.t) / 3);
2583
+ return { dx, dy: slope * dx };
2584
+ }
2585
+ /**
2586
+ * 通道求值:keys = [{ t, interp?, ease?, hIn?{dx,dy}, hOut?{dx,dy} }]
2587
+ * interp: 'auto'(默认,smoothstep/legacy) | 'linear' | 'constant' | 'bezier'
2588
+ * bezier 时用显式手柄(dx=时间偏移, dy=值偏移),缺省走自动切线。
2589
+ */
2590
+ export function evalKeyChannel(keys, valueOf, time) {
2591
+ const ks = (keys || []).filter((k) => k && k.t !== undefined).slice().sort((a, b) => a.t - b.t);
2592
+ if (!ks.length) return null;
2593
+ const val = (k) => { const v = Number(valueOf(k)); return Number.isFinite(v) ? v : 0; };
2594
+ const t = Math.max(0, Number(time) || 0);
2595
+ if (t <= ks[0].t) return val(ks[0]);
2596
+ if (t >= ks[ks.length - 1].t) return val(ks[ks.length - 1]);
2597
+ let i = 0;
2598
+ while (i < ks.length - 1 && ks[i + 1].t <= t) i++;
2599
+ const a = ks[i], b = ks[i + 1];
2600
+ const span = b.t - a.t;
2601
+ const k = span > 1e-6 ? (t - a.t) / span : 0;
2602
+ const va = val(a), vb = val(b);
2603
+ const interp = String((b.interp || a.interp || 'auto')).toLowerCase();
2604
+ if (interp === 'constant') return va;
2605
+ if (interp === 'bezier') {
2606
+ const ho = a.hOut && Number.isFinite(Number(a.hOut.dx)) ? { dx: Number(a.hOut.dx), dy: Number(a.hOut.dy) || 0 } : (() => { const h = autoHandle(ks, i, val); return { dx: h.dx, dy: h.dy }; })();
2607
+ const hi = b.hIn && Number.isFinite(Number(b.hIn.dx)) ? { dx: Number(b.hIn.dx), dy: Number(b.hIn.dy) || 0 } : (() => { const h = autoHandle(ks, i + 1, val); return { dx: -h.dx, dy: -h.dy }; })();
2608
+ let x1 = a.t + ho.dx, x2 = b.t + hi.dx;
2609
+ // 手柄 x 限幅(对应 Blender BKE_fcurve_correct_bezpart:防止时间轴回环)
2610
+ x1 = Math.min(Math.max(x1, a.t + 1e-6), b.t - 1e-6);
2611
+ x2 = Math.min(Math.max(x2, a.t + 1e-6), b.t - 1e-6);
2612
+ if (x2 < x1) { const mid = (x1 + x2) / 2; x1 = mid; x2 = mid; }
2613
+ const u = bezierSolveT(a.t, x1, x2, b.t, t);
2614
+ return bez1(va, va + ho.dy, vb + hi.dy, vb, u);
2615
+ }
2616
+ const e = interp === 'linear' ? k : easeFactor(b.ease || a.ease || 'auto', k);
2617
+ return va + (vb - va) * e;
2618
+ }
2619
+
1846
2620
  export function interpolatePoseKeys(poseKeys, time) {
1847
2621
  if (!Array.isArray(poseKeys) || !poseKeys.length) return null;
1848
2622
  const ks = poseKeys.filter((k) => k && k.t !== undefined).sort((a, b) => a.t - b.t);
@@ -1856,19 +2630,27 @@ export function interpolatePoseKeys(poseKeys, time) {
1856
2630
  if (t >= ks[i].t && t <= ks[i + 1].t) { a = ks[i]; b = ks[i + 1]; break; }
1857
2631
  }
1858
2632
  const span = b.t - a.t;
1859
- const k = span > 1e-6 ? (t - a.t) / span : 0;
2633
+ const rawK = span > 1e-6 ? (t - a.t) / span : 0;
2634
+ // 默认线性(向后兼容既有 poseKeys 语义);需要缓动时在关键帧上显式写 ease:'auto' 等
2635
+ const k = easeFactor(b.ease || a.ease || 'linear', rawK);
1860
2636
  const pa = a.bonePose || {}, pb = b.bonePose || {};
1861
- // 合并两帧出现过的所有骨骼索引
1862
2637
  const idxSet = new Set([...Object.keys(pa), ...Object.keys(pb)]);
1863
2638
  const out = {};
1864
2639
  for (const key of idxSet) {
1865
2640
  const ra = pa[key] || [0, 0, 0];
1866
2641
  const rb = pb[key] || [0, 0, 0];
1867
- out[key] = [
1868
- (Number(ra[0]) || 0) + ((Number(rb[0]) || 0) - (Number(ra[0]) || 0)) * k,
1869
- (Number(ra[1]) || 0) + ((Number(rb[1]) || 0) - (Number(ra[1]) || 0)) * k,
1870
- (Number(ra[2]) || 0) + ((Number(rb[2]) || 0) - (Number(ra[2]) || 0)) * k,
1871
- ];
2642
+ if (ra.length === 4 || rb.length === 4) {
2643
+ // 四元数通道:最短弧 slerp(Blender bone rotation 语义);3 分量按欧拉转四元数兜底
2644
+ const qa = ra.length === 4 ? ra : eulerToQuat(Number(ra[0]) || 0, Number(ra[1]) || 0, Number(ra[2]) || 0);
2645
+ const qb = rb.length === 4 ? rb : eulerToQuat(Number(rb[0]) || 0, Number(rb[1]) || 0, Number(rb[2]) || 0);
2646
+ out[key] = quatSlerp(qa, qb, k);
2647
+ } else {
2648
+ out[key] = [
2649
+ (Number(ra[0]) || 0) + ((Number(rb[0]) || 0) - (Number(ra[0]) || 0)) * k,
2650
+ (Number(ra[1]) || 0) + ((Number(rb[1]) || 0) - (Number(ra[1]) || 0)) * k,
2651
+ (Number(ra[2]) || 0) + ((Number(rb[2]) || 0) - (Number(ra[2]) || 0)) * k,
2652
+ ];
2653
+ }
1872
2654
  }
1873
2655
  return out;
1874
2656
  }
@@ -1990,6 +2772,26 @@ export function buildStageFromSpec(spec) {
1990
2772
  const pose = poseOf(c.pose);
1991
2773
  if (pose) ob.poseId = pose;
1992
2774
  if (c.material && typeof c.material === 'object') ob.material = { ...c.material };
2775
+ if (c.skinning && /^(lbs|dqs|auto)$/i.test(String(c.skinning))) ob.skinning = String(c.skinning).toLowerCase();
2776
+ if (Array.isArray(c.ik) && c.ik.length) {
2777
+ ob.ik = c.ik.filter((x) => x && Array.isArray(x.chain) && x.chain.length >= 3 && x.target)
2778
+ .map((x) => { const o = { chain: x.chain.map(String), target: x.target }; if (x.pole) o.pole = x.pole; if (x.weight !== undefined) o.weight = Number(x.weight); return o; });
2779
+ if (!ob.ik.length) delete ob.ik;
2780
+ }
2781
+ if (Array.isArray(c.constraints) && c.constraints.length) {
2782
+ ob.constraints = c.constraints.filter((x) => x && x.bone && x.type).map((x) => Object.assign({}, x));
2783
+ if (!ob.constraints.length) delete ob.constraints;
2784
+ }
2785
+ if (Array.isArray(c.clips) && c.clips.length) {
2786
+ ob.clips = c.clips.filter((x) => x && Array.isArray(x.keys) && x.keys.length).map((x) => {
2787
+ const o = { keys: x.keys, blend: String(x.blend || 'replace'), influence: Number(x.influence === undefined ? 1 : x.influence) };
2788
+ if (x.t0 !== undefined) o.t0 = Number(x.t0);
2789
+ if (x.t1 !== undefined) o.t1 = Number(x.t1);
2790
+ if (x.loop) o.loop = true;
2791
+ return o;
2792
+ });
2793
+ if (!ob.clips.length) delete ob.clips;
2794
+ }
1993
2795
  addPath(ob, c.path, c.pathMode);
1994
2796
  if (ob.path && c.pathFace === false) ob.pathFace = false;
1995
2797
  if (c.locomotion && typeof c.locomotion === 'object') ob.locomotion = { ...c.locomotion };
@@ -2014,6 +2816,18 @@ export function buildStageFromSpec(spec) {
2014
2816
  color: String(p.color || '#b3b8bf'),
2015
2817
  };
2016
2818
  if (p.material && typeof p.material === 'object') pob.material = { ...p.material };
2819
+ // 道具关键帧:与角色同构(t/pos/rotY/rx/rz/ease),供长枪等跟手(applyPlayhead 对所有对象统一应用)
2820
+ if (Array.isArray(p.keys)) {
2821
+ pob.keys = p.keys.map((k) => ({
2822
+ t: num(k.t, 0),
2823
+ pos: v3of(k.pos, 0),
2824
+ rotY: num(k.rotY, 0),
2825
+ rx: num(k.rx, 0),
2826
+ rz: num(k.rz, 0),
2827
+ ease: String(k.ease || 'auto'),
2828
+ ...(k.interp ? { interp: String(k.interp) } : {}),
2829
+ })).sort((x, y) => x.t - y.t);
2830
+ }
2017
2831
  objects.push(pob);
2018
2832
  }
2019
2833
  const cameras = (Array.isArray(sp.cameras) ? sp.cameras : []).map((c, i) => {
@@ -2119,6 +2933,10 @@ export function compileStageSpec(scene) {
2119
2933
  if (ob.scale && (Number(ob.scale.x) !== 1 || Number(ob.scale.y) !== 1 || Number(ob.scale.z) !== 1)) c.scale = arr(ob.scale);
2120
2934
  if (ob.poseId) c.pose = ob.poseId;
2121
2935
  if (ob.material) c.material = { ...ob.material };
2936
+ if (ob.skinning) c.skinning = ob.skinning;
2937
+ if (Array.isArray(ob.ik) && ob.ik.length) c.ik = JSON.parse(JSON.stringify(ob.ik));
2938
+ if (Array.isArray(ob.constraints) && ob.constraints.length) c.constraints = JSON.parse(JSON.stringify(ob.constraints));
2939
+ if (Array.isArray(ob.clips) && ob.clips.length) c.clips = JSON.parse(JSON.stringify(ob.clips));
2122
2940
  if (ob.bonePose && Object.keys(ob.bonePose).length) c.bonePose = JSON.parse(JSON.stringify(ob.bonePose));
2123
2941
  if (Array.isArray(ob.path) && ob.path.length) {
2124
2942
  c.path = ob.path.map(arr); c.pathMode = ob.pathMode || 'curve';
@@ -2136,6 +2954,9 @@ export function compileStageSpec(scene) {
2136
2954
  scale: arr(ob.scale || v3(1, 1, 1)), color: ob.color,
2137
2955
  visible: ob.visible === false ? false : undefined,
2138
2956
  material: ob.material ? { ...ob.material } : undefined,
2957
+ ...(Array.isArray(ob.keys) && ob.keys.length
2958
+ ? { keys: ob.keys.map((k) => ({ t: round3(k.t), pos: arr(k.pos), rotY: round3(k.rotY || 0), rx: round3(k.rx || 0), rz: round3(k.rz || 0), ease: k.ease || 'auto' })) }
2959
+ : {}),
2139
2960
  });
2140
2961
  }
2141
2962
  }
@@ -2415,6 +3236,33 @@ export function applyPlayhead(scene, t) {
2415
3236
  }
2416
3237
  if (ry) next.pos = v3(next.pos.x, next.pos.y + ry, next.pos.z);
2417
3238
  }
3239
+ // 姿态中心偏移(供 gizmo / 拾取跟随身体,而非留在地面原点)
3240
+ if (next.kind === 'model' && gltfAssetOf(next.assetId)) {
3241
+ try {
3242
+ const a2 = gltfAssetOf(next.assetId);
3243
+ const clipName = next.clip || (a2.animations[0] && a2.animations[0].name) || '';
3244
+ const clip = clipName ? a2.clipByName(clipName) : null;
3245
+ let hipsY = null;
3246
+ if (clip && Array.isArray(clip.tracks)) {
3247
+ const hipTrack = clip.tracks.find((tr) => tr.path === 'translation' && tr.node === (a2.bones.find((x) => String(x.name).replace(/^mixamorig:?/i, '') === 'Hips') || {}).nodeIdx);
3248
+ if (hipTrack) {
3249
+ const tv = hipTrack.times, vv = hipTrack.vals;
3250
+ const n = tv.length;
3251
+ if (n) {
3252
+ let i2 = 0;
3253
+ const tt = Math.max(tv[0], Math.min(tv[n - 1], time));
3254
+ while (i2 < n - 2 && tv[i2 + 1] < tt) i2++;
3255
+ const k2 = (tt - tv[i2]) / Math.max(1e-6, tv[i2 + 1] - tv[i2]);
3256
+ hipsY = (vv[i2 * 3 + 1] * (1 - k2) + vv[(i2 + 1) * 3 + 1] * k2) / 100; // 厘米 → 米
3257
+ }
3258
+ }
3259
+ }
3260
+ if (hipsY !== null && Number.isFinite(hipsY)) {
3261
+ // 髋的实际世界高度就作为 gizmo 中心高度(站立≈1.04,倒地≈0.35)
3262
+ next.poseCenterY = Math.max(0.15, Math.min(1.2, hipsY));
3263
+ }
3264
+ } catch { /* 姿态中心算不出就退回原点 */ }
3265
+ }
2418
3266
  // 姿态关键帧:优先于静态 bonePose(姿态随时间过渡)
2419
3267
  if (Array.isArray(o.poseKeys) && o.poseKeys.length) {
2420
3268
  const ip = interpolatePoseKeys(o.poseKeys, time);
@@ -2447,15 +3295,19 @@ export function applyPlayhead(scene, t) {
2447
3295
  }
2448
3296
  }
2449
3297
  }
2450
- // 对象关键帧:位置 / 朝向 / 翻滚
3298
+ // 对象关键帧:位置 / 朝向 / 翻滚(逐通道求值,支持 bezier/linear/constant + ease)
2451
3299
  if (Array.isArray(o.keys) && o.keys.length) {
2452
- const pr = pickPair(o.keys);
2453
- if (pr) {
2454
- next.pos = lerp3(pr.a.pos || o.pos, pr.b.pos || o.pos, pr.k);
2455
- next.rotY = lerp(Number(pr.a.rotY) || 0, Number(pr.b.rotY) || 0, pr.k);
2456
- next.rx = lerp(Number(pr.a.rx) || 0, Number(pr.b.rx) || 0, pr.k);
2457
- next.rz = lerp(Number(pr.a.rz) || 0, Number(pr.b.rz) || 0, pr.k);
2458
- }
3300
+ const kx = evalKeyChannel(o.keys, (kk) => (kk.pos ? kk.pos.x : undefined), time);
3301
+ const ky = evalKeyChannel(o.keys, (kk) => (kk.pos ? kk.pos.y : undefined), time);
3302
+ const kz = evalKeyChannel(o.keys, (kk) => (kk.pos ? kk.pos.z : undefined), time);
3303
+ const kr = evalKeyChannel(o.keys, (kk) => kk.rotY, time);
3304
+ const kx2 = evalKeyChannel(o.keys, (kk) => kk.rx, time);
3305
+ const kz2 = evalKeyChannel(o.keys, (kk) => kk.rz, time);
3306
+ const hasPosKey = o.keys.some((kk) => kk && kk.pos);
3307
+ if (hasPosKey) next.pos = v3(kx === null ? o.pos.x : kx, ky === null ? o.pos.y : ky, kz === null ? o.pos.z : kz);
3308
+ next.rotY = kr === null ? (Number(o.rotY) || 0) : kr;
3309
+ next.rx = kx2 === null ? (Number(o.rx) || 0) : kx2;
3310
+ next.rz = kz2 === null ? (Number(o.rz) || 0) : kz2;
2459
3311
  }
2460
3312
  // 路径优先级高于对象位置关键帧:但只接管平面 X/Z,**竖直 Y 保留关键帧的高度**
2461
3313
  // (否则翻越/跳跃的关键帧抬升会被路径压回地面 —— 用户实测"翻不过去")
@@ -2676,7 +3528,7 @@ export function cameraFlyStep(cam, input, dt, opts) {
2676
3528
  * 单位一律是 CSS 像素 → 同一相机/同一 CSS 尺寸下两者必然一致(历史 bug:绘制用设备像素、拾取用 CSS,命中区偏移)。
2677
3529
  */
2678
3530
  export function gizmoMetrics(cam, sel, hCss, originOverride) {
2679
- const origin = originOverride || v3(sel.pos.x, sel.pos.y, sel.pos.z);
3531
+ const origin = originOverride || v3(sel.pos.x, sel.pos.y + (Number(sel.poseCenterY) || 0), sel.pos.z);
2680
3532
  const depth = depthOf(cam, origin);
2681
3533
  const pxWorld = pixelSizeAt(cam, hCss, depth);
2682
3534
  const minWorld = Math.max(0.25, (Number(sel.h) || 1.7) / 3);
@@ -2783,6 +3635,97 @@ function matchBone(bones, kw) {
2783
3635
  for (let i = 0; i < bones.length; i++) if (shortBoneName(bones[i].name).toLowerCase().endsWith(want)) return i;
2784
3636
  return -1;
2785
3637
  }
3638
+ /**
3639
+ * 通用人形骨架映射:把任意模型的骨骼名归一化到标准语义关节。
3640
+ * 覆盖 Mixamo / VRM(0.x,1.0) / Daz-Genesis / MakeHuman / Unreal / Blender(.L/.R) / 裸名。
3641
+ * 返回 { semantic: nodeIdx } —— 动作只以语义关节表达,因此同一动作可套到任意人形模型。
3642
+ */
3643
+ const CANON_BONES = [
3644
+ 'Hips', 'Spine', 'Chest', 'Neck', 'Head',
3645
+ 'LeftShoulder', 'LeftArm', 'LeftForeArm', 'LeftHand',
3646
+ 'RightShoulder', 'RightArm', 'RightForeArm', 'RightHand',
3647
+ 'LeftUpLeg', 'LeftLeg', 'LeftFoot', 'LeftToeBase',
3648
+ 'RightUpLeg', 'RightLeg', 'RightFoot', 'RightToeBase',
3649
+ ];
3650
+ /** 各骨架上常见的别名(key 为语义名,值为可接受的正则片段)。 */
3651
+ const RIG_ALIASES = {
3652
+ Hips: ['hips', 'hip', 'pelvis'],
3653
+ Spine: ['spine', 'spine1', 'abdomen', 'abdomenlower', 'lowerback'],
3654
+ Chest: ['chest', 'spine2', 'upperchest', 'torso', 'abdomenupper'],
3655
+ Neck: ['neck', 'necklower'],
3656
+ Head: ['head', 'skull'],
3657
+ LeftShoulder: ['lshoulder', 'lcollar', 'lclavicle'], LeftArm: ['larm', 'lshldr', 'lupperarm'], LeftForeArm: ['lforearm', 'llowerarm', 'lelbow'], LeftHand: ['lhand', 'lwrist'],
3658
+ RightShoulder: ['rshoulder', 'rcollar', 'rclavicle'], RightArm: ['rarm', 'rshldr', 'rupperarm'], RightForeArm: ['rforearm', 'rlowerarm', 'relbow'], RightHand: ['rhand', 'rwrist'],
3659
+ LeftUpLeg: ['lupleg', 'lthigh'], LeftLeg: ['lleg', 'lshin'], LeftFoot: ['lfoot'], LeftToeBase: ['ltoe'],
3660
+ RightUpLeg: ['rupleg', 'rthigh'], RightLeg: ['rleg', 'rshin'], RightFoot: ['rfoot'], RightToeBase: ['rtoe'],
3661
+ };
3662
+ /** 骨骼名归一化:去前缀、去分隔符、小写、统一左右(_.L / _L / .Left / left)。 */
3663
+ function canonBoneName(name) {
3664
+ let n = String(name || '');
3665
+ n = n.replace(/^mixamorig:?/i, '');
3666
+ n = n.replace(/^bip_?\d*_?/i, '');
3667
+ n = n.toLowerCase();
3668
+ // 统一左右标记:把 left/right、_l/_r、.l/.r、-l/-r、l_/r_ 统一成前缀 l/r
3669
+ let side = '';
3670
+ if (/(lef|^l[._\-\s]|_l$|\.l$|\bl$)/.test(n)) side = 'l';
3671
+ else if (/(righ|^r[._\-\s]|_r$|\.r$|\br$)/.test(n)) side = 'r';
3672
+ // 去掉所有分隔符与左右词
3673
+ n = n.replace(/[\.\-_\s]/g, '').replace(/^(left|right)/, '');
3674
+ n = n.replace(/(left|right)$/, '');
3675
+ // 语义归一:upperleg/thigh → upleg;lowerleg/shin/calf → leg;upperarm → arm;lowerarm/forearm → forearm
3676
+ n = n.replace(/upperleg/g, 'upleg').replace(/thigh/g, 'upleg');
3677
+ n = n.replace(/lowerleg/g, 'leg').replace(/shin/g, 'leg').replace(/calf/g, 'leg');
3678
+ n = n.replace(/upperarm/g, 'arm');
3679
+ n = n.replace(/lowerarm/g, 'forearm');
3680
+ n = n.replace(/clavicle/g, 'shoulder').replace(/collar/g, 'shoulder').replace(/shldr/g, 'shoulder');
3681
+ n = n.replace(/toes|toebase/g, 'toe');
3682
+ n = n.replace(/pelvis/g, 'hips');
3683
+ return side + n;
3684
+ }
3685
+ /** 把任意骨架映射到标准语义关节;返回 { semantic: nodeIdx, coverage, missing }。 */
3686
+ export function autoRigMap(asset) {
3687
+ const bones = (asset && asset.bones) || [];
3688
+ const nodes = (asset && asset.nodes) || [];
3689
+ const byCanon = new Map();
3690
+ for (const b of bones) {
3691
+ const c = canonBoneName(b.name);
3692
+ if (!byCanon.has(c)) byCanon.set(c, b.nodeIdx);
3693
+ }
3694
+ const semantic = {};
3695
+ for (const [sem, aliases] of Object.entries(RIG_ALIASES)) {
3696
+ let nodeIdx = -1;
3697
+ for (const al of aliases) {
3698
+ for (const [c, ni] of byCanon) {
3699
+ if (c === al) { nodeIdx = ni; break; }
3700
+ }
3701
+ if (nodeIdx >= 0) break;
3702
+ }
3703
+ if (nodeIdx < 0) {
3704
+ // 二次匹配:包含关系(处理 leftarm_01 之类的变体)
3705
+ for (const al of aliases) {
3706
+ for (const [c, ni] of byCanon) {
3707
+ if (c.includes(al)) { nodeIdx = ni; break; }
3708
+ }
3709
+ if (nodeIdx >= 0) break;
3710
+ }
3711
+ }
3712
+ if (nodeIdx >= 0) semantic[sem] = nodeIdx;
3713
+ }
3714
+ const total = CANON_BONES.length;
3715
+ const got = Object.keys(semantic).length;
3716
+ return {
3717
+ semantic,
3718
+ coverage: got / total,
3719
+ missing: CANON_BONES.filter((k) => !(k in semantic)),
3720
+ total: nodes.length,
3721
+ };
3722
+ }
3723
+ /** 读取资产某节点的 rest 局部旋转四元数。 */
3724
+ export function restQuatOf(asset, nodeIdx) {
3725
+ const n = (asset && asset.nodes && asset.nodes[nodeIdx]) || null;
3726
+ if (!n || !n.r) return [0, 0, 0, 1];
3727
+ return [Number(n.r[0]) || 0, Number(n.r[1]) || 0, Number(n.r[2]) || 0, Number(n.r[3]) || 1];
3728
+ }
2786
3729
  /** 默认场景(新建预演时)。 */
2787
3730
  export function directorDefaultScene(title) {
2788
3731
  const t = String(title || '预演场景');
@@ -2941,7 +3884,7 @@ export function directorObjectFaces(ob) {
2941
3884
  const typeId = actorTypeIdOf(presetId);
2942
3885
  const h = Number(ob.h) || (ACTOR_PRESETS.find((p) => p.id === presetId) || {}).h || 1.78;
2943
3886
  const sk = skinModel(asset, ob.clip || '', Number(ob.animTime) || 0, null, null,
2944
- Object.assign({}, bodyShapeOf(typeId), ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}));
3887
+ Object.assign({}, bodyShapeOf(typeId), ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}, ob.skinning ? { skinning: ob.skinning } : {}, ob.ik ? { ik: ob.ik } : {}, ob.constraints ? { constraints: ob.constraints } : {}, ob.clips ? { clips: ob.clips } : {}));
2945
3888
  const scale = (Number(asset.rawHeight) > 0 ? h / asset.rawHeight : 1) * (Number(ob.s) || 1);
2946
3889
  const ry = Number(ob.rotY) || 0, c0 = Math.cos(ry), s0 = Math.sin(ry);
2947
3890
  const idx = asset.indices || [];
@@ -2977,7 +3920,8 @@ export function directorObjectFaces(ob) {
2977
3920
  if (asset && asset.positions) {
2978
3921
  const shape = Object.assign({}, ob.typeId ? bodyShapeOf(ob.typeId) : {},
2979
3922
  ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {},
2980
- ob.poseBlend ? { poseBlend: ob.poseBlend } : {});
3923
+ ob.poseBlend ? { poseBlend: ob.poseBlend } : {}, ob.skinning ? { skinning: ob.skinning } : {},
3924
+ ob.ik ? { ik: ob.ik } : {}, ob.constraints ? { constraints: ob.constraints } : {}, ob.clips ? { clips: ob.clips } : {});
2981
3925
  const sk = skinModel(asset, ob.clip || '', Number(ob.animTime) || 0, null, null, shape);
2982
3926
  const scale = (Number(asset.rawHeight) > 0 ? (Number(ob.h) || 1.78) / asset.rawHeight : 1);
2983
3927
  const out = [];
@@ -3963,10 +4907,10 @@ export function createDirectorGlRenderer(canvas) {
3963
4907
  const asset = gltfAssetOf(ob.assetId);
3964
4908
  if (!asset) continue;
3965
4909
  const poseKey = (ob.poseId || '') + '|' + (Array.isArray(ob.poseAnim) ? ob.poseAnim.map((p) => p.poseId + '@' + p.t).join(',') + '#' + (Number(ob.animTime) || 0).toFixed(3) : '') + '|' + (ob.bonePose ? Object.keys(ob.bonePose).sort().map((bi) => bi + '=' + (ob.bonePose[bi] || []).map((v) => Number(v).toFixed(3)).join(',')).join(';') : '');
3966
- const kk = ob.id + ":" + (ob.clip || "") + ":" + (Number(ob.animTime) || 0).toFixed(3) + ":" + (ob.typeId || "") + ":" + poseKey + ":shadowV2";
4910
+ const kk = ob.id + ":" + (ob.clip || "") + ":" + (Number(ob.animTime) || 0).toFixed(3) + ":" + (ob.typeId || "") + ":" + (ob.skinning || "lbs") + ":" + (ob.ik ? JSON.stringify(ob.ik) : "") + ":" + (ob.constraints ? JSON.stringify(ob.constraints) : "") + ":" + poseKey + ":shadowV2";
3967
4911
  let sk = skCache.get(kk);
3968
4912
  if (!sk) {
3969
- sk = skinModel(asset, ob.clip || (asset.animations[0] && asset.animations[0].name), Number(ob.animTime) || 0, null, null, Object.assign({}, ob.typeId ? bodyShapeOf(ob.typeId) : {}, ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}, ob.poseBlend ? { poseBlend: ob.poseBlend } : {}));
4913
+ sk = skinModel(asset, ob.clip || (asset.animations[0] && asset.animations[0].name), Number(ob.animTime) || 0, null, null, Object.assign({}, ob.typeId ? bodyShapeOf(ob.typeId) : {}, ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}, ob.poseBlend ? { poseBlend: ob.poseBlend } : {}, ob.skinning ? { skinning: ob.skinning } : {}, ob.ik ? { ik: ob.ik } : {}, ob.constraints ? { constraints: ob.constraints } : {}, ob.clips ? { clips: ob.clips } : {}));
3970
4914
  if (skCache.size > 40) skCache.clear();
3971
4915
  skCache.set(kk, sk);
3972
4916
  }
@@ -4437,10 +5381,10 @@ export function createDirectorGlRenderer(canvas) {
4437
5381
  if (asset) {
4438
5382
  // 缓存键必须包含「体型 + 骨骼姿态」:姿态一改就得重新蒙皮,否则永远命中旧网格
4439
5383
  const poseKey = (ob.poseId || '') + '|' + (ob.bonePose ? Object.keys(ob.bonePose).sort().map((bi) => bi + '=' + (ob.bonePose[bi] || []).map((v) => Number(v).toFixed(3)).join(',')).join(';') : '');
4440
- const k = ob.id + ':' + (ob.clip || '') + ':' + (Number(ob.animTime) || 0).toFixed(3) + ':' + (ob.typeId || '') + ':' + poseKey;
5384
+ const k = ob.id + ':' + (ob.clip || '') + ':' + (Number(ob.animTime) || 0).toFixed(3) + ':' + (ob.typeId || '') + ':' + (ob.skinning || 'lbs') + ':' + (ob.ik ? JSON.stringify(ob.ik) : '') + ':' + (ob.constraints ? JSON.stringify(ob.constraints) : '') + ':' + poseKey;
4441
5385
  let sk = skCache.get(k);
4442
5386
  if (!sk) {
4443
- sk = skinModel(asset, ob.clip || (asset.animations[0] && asset.animations[0].name), Number(ob.animTime) || 0, null, null, Object.assign({}, ob.typeId ? bodyShapeOf(ob.typeId) : {}, ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}, ob.poseBlend ? { poseBlend: ob.poseBlend } : {}));
5387
+ sk = skinModel(asset, ob.clip || (asset.animations[0] && asset.animations[0].name), Number(ob.animTime) || 0, null, null, Object.assign({}, ob.typeId ? bodyShapeOf(ob.typeId) : {}, ob.bonePose ? { bonePose: ob.bonePose } : {}, ob.poseId ? { poseId: ob.poseId } : {}, ob.poseBlend ? { poseBlend: ob.poseBlend } : {}, ob.skinning ? { skinning: ob.skinning } : {}, ob.ik ? { ik: ob.ik } : {}, ob.constraints ? { constraints: ob.constraints } : {}, ob.clips ? { clips: ob.clips } : {}));
4444
5388
  if (skCache.size > 24) skCache.clear();
4445
5389
  skCache.set(k, sk);
4446
5390
  }
@@ -4607,7 +5551,8 @@ export function createDirectorGlRenderer(canvas) {
4607
5551
  if (o.gizmo && o.selectedId) {
4608
5552
  const sel = (scene.objects || []).find((x) => x.id === o.selectedId);
4609
5553
  if (sel) {
4610
- const origin = v3(sel.pos.x, sel.pos.y, sel.pos.z);
5554
+ // gizmo 跟随姿态中心(倒地/下蹲时身体不在原点:让轴心贴住身体,而非留在地面)
5555
+ const origin = v3(sel.pos.x, sel.pos.y + (Number(sel.poseCenterY) || 0), sel.pos.z);
4611
5556
  // 屏幕恒定大小(Blender: wm_gizmo_calculate_scale):轴长 105 CSS px,环 0.62×轴长。
4612
5557
  // 尺寸唯一来源 gizmoMetrics,拾取用同一个函数 → 不可能再出现「画的位置和点不中」。
4613
5558
  const gm = gizmoMetrics(cam, sel, hCss, origin);