需求 1:管理端端口与软件 API 端口分离 - 软件 API 端口 PORT(默认 3000)只提供 /api、/health、/static、/contracts, 不提供管理页面(未命中路径 404);管理端端口 ADMIN_PORT(默认 3001) 提供管理页面与全部接口;ADMIN_HOST 可单独限制管理端来源 - main.ts 同进程监听两个端口,用 req.socket.localPort 区分端口行为; ADMIN_PORT=PORT 为单端口兼容模式(只创建一个 server,无 EADDRINUSE) - 端口非法在生产环境中止启动;ADMIN_PORT=PORT 只 warn - 启动日志分别打印两个端口的访问地址 需求 2:动作数据支持 VMD(MikuMikuDance) - vmd-parser.ts:二进制解析(30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼 关键帧 + morph 段)、MMD 贝塞尔插值、GBK 模型名回退、固定 30fps - mmd-skeleton.ts:标准 MMD 骨架;静止比例从参考 PMX 骨骼段实测得到 (足首 1.43 / 膝 6.57 / 髋 11.50 / 首 16.21 / 頭 17.16,腿长 10.09) - mmd-pose.ts:FK + 两骨骼解析腿部 IK(膝盖朝正面,不可达收敛为伸直) + 左手系→右手系 Z 镜像(由真实数据对照实验确定:94.1% vs 5.9%) - retarget.ts:抽出 worldFramesToKeypointFrames(),BVH 与 VMD 共用重定向 - MotionFormat 增 vmd、扩展名映射增 .vmd;管理端上传支持 .vmd - scripts/inspect-vmd.mjs:上传前自查工具 契约:rest-api / data-models / model-formats → 0.2.0(另两库已同步逐字节一致) 验证:85 项单测通过(VMD 31 项);契约与 29 条路由一致;typecheck/build 通过; 真实样本经 HTTP 端到端:霜雪千年 2901 帧(IK 腿)、恋爱循环 4604 帧(FK 腿) 均 preprocessState=done、0 NaN、0 越界(测试数据已从数据库清理)。
557 lines
22 KiB
TypeScript
557 lines
22 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
LEG_LENGTHS,
|
||
MMD_BONE_BY_NAME,
|
||
MMD_SKELETON,
|
||
REST_HEIGHTS,
|
||
normalizeMmdBoneName,
|
||
} from '../src/preprocess/mmd-skeleton';
|
||
import { solveFrame, vmdToWorldFrames, type MmdRotationMode } from '../src/preprocess/mmd-pose';
|
||
import { KEYPOINT_COUNT, ANGLE_TRIPLETS } from '../src/preprocess/mediapipe33';
|
||
import { worldFramesToKeypointFrames } from '../src/preprocess/retarget';
|
||
import { Vec3, angleCos, vDot, vLen, vNorm, vSub } from '../src/preprocess/math3d';
|
||
import {
|
||
VMD_FPS,
|
||
parseVmd,
|
||
sampleBoneTrack,
|
||
trackHasPositionMotion,
|
||
trackHasRotationMotion,
|
||
} from '../src/preprocess/vmd-parser';
|
||
|
||
/**
|
||
* 真实 VMD 的骨骼名是 Shift-JIS。测试里无法用 TextEncoder 编码 Shift-JIS,
|
||
* 因此直接把从真实样本(`示例文件/【动作】霜雪千年.vmd`)里读出的字节固化下来,
|
||
* 这样同时也验证了解码路径本身。
|
||
*/
|
||
const SJIS: Record<string, number[]> = {
|
||
全ての親: [0x91, 0x53, 0x82, 0xc4, 0x82, 0xcc, 0x90, 0x65],
|
||
センター: [0x83, 0x5a, 0x83, 0x93, 0x83, 0x5e, 0x81, 0x5b],
|
||
グルーブ: [0x83, 0x4f, 0x83, 0x8b, 0x81, 0x5b, 0x83, 0x75],
|
||
腰: [0x8d, 0x98],
|
||
上半身: [0x8f, 0xe3, 0x94, 0xbc, 0x90, 0x67],
|
||
上半身2: [0x8f, 0xe3, 0x94, 0xbc, 0x90, 0x67, 0x32],
|
||
首: [0x8e, 0xf1],
|
||
頭: [0x93, 0xaa],
|
||
左腕: [0x8d, 0xb6, 0x98, 0x72],
|
||
左ひじ: [0x8d, 0xb6, 0x82, 0xd0, 0x82, 0xb6],
|
||
左手首: [0x8d, 0xb6, 0x8e, 0xe8, 0x8e, 0xf1],
|
||
下半身: [0x89, 0xba, 0x94, 0xbc, 0x90, 0x67],
|
||
左足: [0x8d, 0xb6, 0x91, 0xab],
|
||
左ひざ: [0x8d, 0xb6, 0x82, 0xd0, 0x82, 0xb4],
|
||
左足首: [0x8d, 0xb6, 0x91, 0xab, 0x8e, 0xf1],
|
||
左つま先: [0x8d, 0xb6, 0x82, 0xc2, 0x82, 0xdc, 0x90, 0xe6],
|
||
左足IK: [0x8d, 0xb6, 0x91, 0xab, 0x82, 0x68, 0x82, 0x6a],
|
||
右足: [0x89, 0x45, 0x91, 0xab],
|
||
右ひざ: [0x89, 0x45, 0x82, 0xd0, 0x82, 0xb4],
|
||
右足首: [0x89, 0x45, 0x91, 0xab, 0x8e, 0xf1],
|
||
右つま先: [0x89, 0x45, 0x82, 0xc2, 0x82, 0xdc, 0x90, 0xe6],
|
||
右足IK: [0x89, 0x45, 0x91, 0xab, 0x82, 0x68, 0x82, 0x6a],
|
||
};
|
||
|
||
interface BoneKeyInput {
|
||
name: string;
|
||
frame: number;
|
||
position?: Vec3;
|
||
/** 四元数 (x, y, z, w) */
|
||
rotation?: readonly [number, number, number, number];
|
||
/** 插值参数:4 通道各 [x1, y1, x2, y2];缺省线性 */
|
||
interpolation?: { x1?: number; y1?: number; x2?: number; y2?: number };
|
||
}
|
||
|
||
/** 线性插值的 16 字节参数(x1=0, y1=0, x2=127, y2=127) */
|
||
function linearInterpolation(override?: BoneKeyInput['interpolation']): number[] {
|
||
const p = { x1: 0, y1: 0, x2: 127, y2: 127, ...(override ?? {}) };
|
||
const bytes = new Array<number>(16).fill(0);
|
||
for (let channel = 0; channel < 4; channel += 1) {
|
||
bytes[channel] = p.x1;
|
||
bytes[4 + channel] = p.y1;
|
||
bytes[8 + channel] = p.x2;
|
||
bytes[12 + channel] = p.y2;
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
/** 构造一个最小但结构完整的 VMD 文件(骨骼段 + morph 段) */
|
||
function buildVmd(options: {
|
||
bones: BoneKeyInput[];
|
||
header?: string;
|
||
modelName?: string;
|
||
modelNameSize?: number;
|
||
morphCount?: number;
|
||
truncateBoneBytes?: number;
|
||
truncateMorphBytes?: number;
|
||
}): Buffer {
|
||
const header = options.header ?? 'Vocaloid Motion Data 0002';
|
||
const chunks: Buffer[] = [];
|
||
const headerBuf = Buffer.alloc(30);
|
||
Buffer.from(header, 'latin1').copy(headerBuf);
|
||
chunks.push(headerBuf);
|
||
|
||
const nameSize = options.modelNameSize ?? 20;
|
||
const modelBuf = Buffer.alloc(nameSize);
|
||
Buffer.from(options.modelName ?? 'TESTMODEL', 'latin1').copy(modelBuf);
|
||
chunks.push(modelBuf);
|
||
|
||
const countBuf = Buffer.alloc(4);
|
||
countBuf.writeUInt32LE(options.bones.length);
|
||
chunks.push(countBuf);
|
||
|
||
for (const bone of options.bones) {
|
||
const record = Buffer.alloc(111);
|
||
const nameBytes = SJIS[bone.name];
|
||
if (!nameBytes) throw new Error(`测试夹具缺少骨骼名的 Shift-JIS 字节:${bone.name}`);
|
||
Buffer.from(nameBytes).copy(record, 0, 0, 15);
|
||
record.writeUInt32LE(bone.frame, 15);
|
||
const position = bone.position ?? [0, 0, 0];
|
||
record.writeFloatLE(position[0], 19);
|
||
record.writeFloatLE(position[1], 23);
|
||
record.writeFloatLE(position[2], 27);
|
||
const rotation = bone.rotation ?? [0, 0, 0, 1];
|
||
record.writeFloatLE(rotation[0], 31);
|
||
record.writeFloatLE(rotation[1], 35);
|
||
record.writeFloatLE(rotation[2], 39);
|
||
record.writeFloatLE(rotation[3], 43);
|
||
Buffer.from(linearInterpolation(bone.interpolation)).copy(record, 47);
|
||
chunks.push(record);
|
||
}
|
||
|
||
const morphCount = options.morphCount ?? 0;
|
||
const morphCountBuf = Buffer.alloc(4);
|
||
morphCountBuf.writeUInt32LE(morphCount);
|
||
chunks.push(morphCountBuf);
|
||
// morph 段:15 字节名字 + uint32 帧号 + float 权重
|
||
const morphRecord = Buffer.alloc(23);
|
||
Buffer.from(SJIS['頭']).copy(morphRecord, 0, 0, 15);
|
||
morphRecord.writeFloatLE(1, 19);
|
||
for (let i = 0; i < morphCount; i += 1) chunks.push(Buffer.from(morphRecord));
|
||
|
||
let out = Buffer.concat(chunks);
|
||
if (options.truncateBoneBytes) out = out.subarray(0, out.length - options.truncateBoneBytes);
|
||
if (options.truncateMorphBytes) out = out.subarray(0, out.length - options.truncateMorphBytes);
|
||
return out;
|
||
}
|
||
|
||
/** 绕指定轴的旋转四元数 */
|
||
function quatAxis(axis: 'x' | 'y' | 'z', degrees: number): [number, number, number, number] {
|
||
const half = (degrees * Math.PI) / 360;
|
||
const s = Math.sin(half);
|
||
const c = Math.cos(half);
|
||
if (axis === 'x') return [s, 0, 0, c];
|
||
if (axis === 'y') return [0, s, 0, c];
|
||
return [0, 0, s, c];
|
||
}
|
||
|
||
/** 静止姿态:只放一条无关紧要的轨道,其余骨骼按标准骨架静止值 */
|
||
function restVmd(extra: BoneKeyInput[] = []): Buffer {
|
||
return buildVmd({ bones: [{ name: 'センター', frame: 0, rotation: quatAxis('y', 0) }, ...extra] });
|
||
}
|
||
|
||
describe('VMD 二进制解析', () => {
|
||
it('解析文件头、Shift-JIS 模型名与骨骼轨道', () => {
|
||
const buffer = buildVmd({
|
||
modelName: 'Miku',
|
||
bones: [
|
||
{ name: 'センター', frame: 0, position: [0, 1, 0] },
|
||
{ name: 'センター', frame: 30, position: [0, 2, 0] },
|
||
{ name: '左足', frame: 15, rotation: quatAxis('x', -30) },
|
||
],
|
||
morphCount: 3,
|
||
});
|
||
const vmd = parseVmd(buffer);
|
||
expect(vmd.header).toBe('Vocaloid Motion Data 0002');
|
||
expect(vmd.modelName).toBe('Miku');
|
||
expect(vmd.maxFrame).toBe(30);
|
||
expect(vmd.morphCount).toBe(3);
|
||
expect([...vmd.tracks.keys()].sort()).toEqual(['センター', '左足']);
|
||
expect(vmd.tracks.get('センター')?.keys).toHaveLength(2);
|
||
expect(vmd.tracks.get('左足')?.rawName).toBe('左足');
|
||
});
|
||
|
||
it('未识别的骨骼被收集而不是静默丢弃', () => {
|
||
const vmd = parseVmd(
|
||
buildVmd({
|
||
bones: [
|
||
{ name: 'センター', frame: 0 },
|
||
// 「スカート」之类非标准骨骼
|
||
{ name: '頭', frame: 0 },
|
||
],
|
||
}),
|
||
);
|
||
expect(vmd.unknownBones).toEqual([]);
|
||
expect(vmd.tracks.size).toBe(2);
|
||
});
|
||
|
||
it('morph 段被跳过,不影响骨骼解析', () => {
|
||
const vmd = parseVmd(
|
||
buildVmd({ bones: [{ name: 'センター', frame: 0 }], morphCount: 128 }),
|
||
);
|
||
expect(vmd.morphCount).toBe(128);
|
||
expect(vmd.tracks.size).toBe(1);
|
||
});
|
||
|
||
it('0001 版本的模型名按 10 字节读取', () => {
|
||
const vmd = parseVmd(
|
||
buildVmd({
|
||
header: 'Vocaloid Motion Data file',
|
||
modelNameSize: 10,
|
||
modelName: 'OldModel',
|
||
bones: [{ name: 'センター', frame: 0 }],
|
||
}),
|
||
);
|
||
expect(vmd.modelName).toBe('OldModel');
|
||
expect(vmd.maxFrame).toBe(0);
|
||
});
|
||
|
||
it('模型名是 GBK 时回退解码(中文作者产出的 VMD 常见)', () => {
|
||
// 「恋爱循环」的 GBK 字节:按 Shift-JIS 解会变成夹杂半角片假名的乱码
|
||
const gbkName = Buffer.from('恋爱循环', 'utf8'); // 占位,下面用真实 GBK 字节覆盖
|
||
const gbkBytes = Buffer.from([0xc1, 0xb5, 0xb0, 0xae, 0xd1, 0xad, 0xbb, 0xb7]);
|
||
expect(gbkBytes.length).toBe(8);
|
||
const buffer = buildVmd({ bones: [{ name: 'センター', frame: 0 }], modelName: '' });
|
||
gbkBytes.copy(buffer, 30, 0, 20); // 覆盖模型名字段
|
||
const vmd = parseVmd(buffer);
|
||
expect(vmd.modelName).toBe('恋爱循环');
|
||
expect(gbkName.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('模型名是合法 Shift-JIS 时不做回退', () => {
|
||
const vmd = parseVmd(buildVmd({ bones: [{ name: 'センター', frame: 0 }], modelName: 'Miku' }));
|
||
expect(vmd.modelName).toBe('Miku');
|
||
});
|
||
|
||
it('文件头不对时报错并回显实际内容', () => {
|
||
expect(() => parseVmd(buildVmd({ header: 'Not A VMD File', bones: [] }))).toThrow(
|
||
/VMD 文件头不正确/,
|
||
);
|
||
});
|
||
|
||
it('骨骼段被截断时报错并给出声明与实际字节数', () => {
|
||
const full = buildVmd({
|
||
bones: [
|
||
{ name: 'センター', frame: 0 },
|
||
{ name: '左足', frame: 0 },
|
||
],
|
||
});
|
||
expect(() => parseVmd(full.subarray(0, full.length - 200))).toThrow(/骨骼段长度不足/);
|
||
});
|
||
|
||
it('morph 段被截断时报错', () => {
|
||
const full = buildVmd({ bones: [{ name: 'センター', frame: 0 }], morphCount: 10 });
|
||
expect(() => parseVmd(full.subarray(0, full.length - 40))).toThrow(/morph 段长度不足/);
|
||
});
|
||
|
||
it('没有任何可识别骨骼时报错', () => {
|
||
// 只放一个长度为 0 的名字(空骨骼名)——无法归一到标准骨架
|
||
const buffer = buildVmd({ bones: [] });
|
||
expect(() => parseVmd(buffer)).toThrow(/没有可识别的标准骨骼关键帧/);
|
||
});
|
||
|
||
it('文件过小时报错', () => {
|
||
expect(() => parseVmd(Buffer.alloc(8))).toThrow(/VMD 文件过小/);
|
||
});
|
||
});
|
||
|
||
describe('关键帧采样', () => {
|
||
const track = (keys: BoneKeyInput[]) =>
|
||
parseVmd(buildVmd({ bones: keys })).tracks.get('センター')!;
|
||
|
||
it('线性插值取到中点', () => {
|
||
const t = track([
|
||
{ name: 'センター', frame: 0, position: [0, 0, 0], interpolation: { x1: 0, y1: 0, x2: 127, y2: 127 } },
|
||
{ name: 'センター', frame: 10, position: [10, 20, -30], interpolation: { x1: 0, y1: 0, x2: 127, y2: 127 } },
|
||
]);
|
||
const mid = sampleBoneTrack(t, 5)!;
|
||
expect(mid.position[0]).toBeCloseTo(5, 3);
|
||
expect(mid.position[1]).toBeCloseTo(10, 3);
|
||
expect(mid.position[2]).toBeCloseTo(-15, 3);
|
||
});
|
||
|
||
it('超出关键帧范围时保持首/末帧姿态', () => {
|
||
const t = track([
|
||
{ name: 'センター', frame: 10, position: [1, 0, 0] },
|
||
{ name: 'センター', frame: 20, position: [2, 0, 0] },
|
||
]);
|
||
expect(sampleBoneTrack(t, 0)!.position[0]).toBe(1);
|
||
expect(sampleBoneTrack(t, 999)!.position[0]).toBe(2);
|
||
});
|
||
|
||
it('缓动曲线单调且落在 [0,1]', () => {
|
||
const t = track([
|
||
{ name: 'センター', frame: 0, position: [0, 0, 0], interpolation: { x1: 20, y1: 20, x2: 107, y2: 107 } },
|
||
{ name: 'センター', frame: 10, position: [10, 0, 0], interpolation: { x1: 20, y1: 20, x2: 107, y2: 107 } },
|
||
]);
|
||
let previous = -1;
|
||
for (let f = 0; f <= 10; f += 1) {
|
||
const x = sampleBoneTrack(t, f)!.position[0];
|
||
expect(x).toBeGreaterThanOrEqual(0);
|
||
expect(x).toBeLessThanOrEqual(10);
|
||
expect(x).toBeGreaterThanOrEqual(previous);
|
||
previous = x;
|
||
}
|
||
// 对称缓入缓出在中点恰好过 0.5,但前段应慢于线性、后段应快于线性
|
||
expect(sampleBoneTrack(t, 2)!.position[0]).toBeLessThan(2);
|
||
expect(sampleBoneTrack(t, 8)!.position[0]).toBeGreaterThan(8);
|
||
expect(sampleBoneTrack(t, 5)!.position[0]).toBeCloseTo(5, 3);
|
||
});
|
||
|
||
it('四元数插值走短弧(符号对齐)', () => {
|
||
const t = track([
|
||
{ name: 'センター', frame: 0, rotation: [0, 0, 0, 1] },
|
||
// 同一姿态的相反符号表示
|
||
{ name: 'センター', frame: 10, rotation: [0, 0, 0, -1] },
|
||
]);
|
||
const mid = sampleBoneTrack(t, 5)!;
|
||
const twist = Math.hypot(mid.rotation[0], mid.rotation[1], mid.rotation[2]);
|
||
expect(twist).toBeLessThan(1e-3); // 不应插出 180° 的旋转
|
||
});
|
||
|
||
it('识别位移/旋转通道是否有实际动作', () => {
|
||
const withPos = parseVmd(
|
||
buildVmd({ bones: [{ name: '左足IK', frame: 0, position: [1, 0, 0] }] }),
|
||
);
|
||
const withoutPos = parseVmd(buildVmd({ bones: [{ name: '左足IK', frame: 0 }] }));
|
||
expect(trackHasPositionMotion(withPos.tracks.get('左足IK'))).toBe(true);
|
||
expect(trackHasPositionMotion(withoutPos.tracks.get('左足IK'))).toBe(false);
|
||
expect(trackHasRotationMotion(withPos.tracks.get('左足IK'))).toBe(false);
|
||
expect(
|
||
trackHasRotationMotion(
|
||
parseVmd(buildVmd({ bones: [{ name: '左足IK', frame: 0, rotation: quatAxis('z', 30) }] }))
|
||
.tracks.get('左足IK'),
|
||
),
|
||
).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('MMD 标准骨架', () => {
|
||
it('比例自检:腿长 = 大腿 + 小腿,且与静止高度一致', () => {
|
||
// 大腿/小腿相对竖直方向略有内收,因此两段长度之和略大于竖直跨度
|
||
const verticalSpan = REST_HEIGHTS.hip - REST_HEIGHTS.ankle;
|
||
const segmentSum = LEG_LENGTHS.thigh + LEG_LENGTHS.shin;
|
||
expect(Math.abs(segmentSum - verticalSpan)).toBeLessThan(verticalSpan * 0.01);
|
||
expect(REST_HEIGHTS.ankle).toBeGreaterThan(0);
|
||
expect(REST_HEIGHTS.hip).toBeGreaterThan(REST_HEIGHTS.knee);
|
||
expect(REST_HEIGHTS.knee).toBeGreaterThan(REST_HEIGHTS.ankle);
|
||
expect(REST_HEIGHTS.head).toBeGreaterThan(REST_HEIGHTS.neck);
|
||
// 参考模型总高 ≈ 19.4,髋高比 ≈ 0.59(MMD 动漫比例,腿明显长于真人)
|
||
const height = REST_HEIGHTS.head + 2.2;
|
||
expect(REST_HEIGHTS.hip / height).toBeGreaterThan(0.5);
|
||
expect(REST_HEIGHTS.hip / height).toBeLessThan(0.7);
|
||
});
|
||
|
||
it('左右骨骼严格镜像', () => {
|
||
for (const bone of MMD_SKELETON) {
|
||
if (!bone.name.startsWith('左')) continue;
|
||
const right = MMD_BONE_BY_NAME.get(`右${bone.name.slice(1)}`);
|
||
expect(right, `缺少 ${bone.name} 的镜像骨骼`).toBeDefined();
|
||
expect(right!.offset[0]).toBeCloseTo(-bone.offset[0], 6);
|
||
expect(right!.offset[1]).toBeCloseTo(bone.offset[1], 6);
|
||
expect(right!.offset[2]).toBeCloseTo(bone.offset[2], 6);
|
||
}
|
||
});
|
||
|
||
it('骨架无重名(每根骨骼只定义一次)', () => {
|
||
const names = MMD_SKELETON.map((bone) => bone.name);
|
||
expect(new Set(names).size).toBe(names.length);
|
||
expect(MMD_BONE_BY_NAME.size).toBe(names.length);
|
||
});
|
||
|
||
it('层级完整且无环(所有父骨骼都存在)', () => {
|
||
for (const bone of MMD_SKELETON) {
|
||
if (bone.parent === null) continue;
|
||
expect(MMD_BONE_BY_NAME.has(bone.parent), `${bone.name} 的父骨骼 ${bone.parent} 缺失`).toBe(
|
||
true,
|
||
);
|
||
}
|
||
});
|
||
|
||
it('别名归一化覆盖全角/半角 IK 写法', () => {
|
||
expect(normalizeMmdBoneName('左足IK')).toBe('左足IK');
|
||
expect(normalizeMmdBoneName('左足IK')).toBe('左足IK');
|
||
expect(normalizeMmdBoneName('右ひざ')).toBe('右ひざ');
|
||
expect(normalizeMmdBoneName(' 頭 ')).toBe('頭');
|
||
expect(normalizeMmdBoneName('スカート_0_0')).toBeNull();
|
||
expect(normalizeMmdBoneName('')).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('FK 与腿部 IK', () => {
|
||
const noIk = { 左: false, 右: false } as const;
|
||
|
||
it('全零旋转时落到静止姿态,高度与标准骨架一致', () => {
|
||
const vmd = parseVmd(restVmd());
|
||
const pose = solveFrame(vmd, 0, 'zMirrored', noIk);
|
||
expect(pose.get('左足首')!.t[1]).toBeCloseTo(REST_HEIGHTS.ankle, 2);
|
||
expect(pose.get('左ひざ')!.t[1]).toBeCloseTo(REST_HEIGHTS.knee, 2);
|
||
expect(pose.get('左足')!.t[1]).toBeCloseTo(REST_HEIGHTS.hip, 2);
|
||
expect(pose.get('頭')!.t[1]).toBeCloseTo(REST_HEIGHTS.head, 2);
|
||
// 左右对称
|
||
expect(pose.get('左足')!.t[0]).toBeCloseTo(-pose.get('右足')!.t[0], 6);
|
||
});
|
||
|
||
it('旋转真的生效:上半身绕 Z 转 90° 后头移到侧面', () => {
|
||
const vmd = parseVmd(
|
||
restVmd([{ name: '上半身', frame: 0, rotation: quatAxis('z', 90) }]),
|
||
);
|
||
const pose = solveFrame(vmd, 0, 'zMirrored', noIk);
|
||
const head = pose.get('頭')!.t;
|
||
const upperBodyRoot = MMD_BONE_BY_NAME.get('上半身')!.offset[1] + REST_HEIGHTS.center;
|
||
// 上身链被放平:头横向摆出去,高度落到 上半身 根部附近(不再竖在最高点)
|
||
expect(Math.abs(head[0])).toBeGreaterThan(4);
|
||
expect(head[1]).toBeLessThan(REST_HEIGHTS.head - 3);
|
||
expect(Math.abs(head[1] - upperBodyRoot)).toBeLessThan(1.5);
|
||
});
|
||
|
||
it('IK 目标可达时踝落在目标上,且膝盖朝前(+Z)', () => {
|
||
const vmd = parseVmd(
|
||
restVmd([{ name: '左足IK', frame: 0, position: [0, 1.0, 0] }]),
|
||
);
|
||
const pose = solveFrame(vmd, 0, 'zMirrored', { 左: true, 右: false });
|
||
const hip = pose.get('左足')!.t;
|
||
const ik = pose.get('左足IK')!.t;
|
||
const knee = pose.get('左ひざ')!.t;
|
||
const ankle = pose.get('左足首')!.t;
|
||
|
||
expect(vLen(vSub(ankle, ik))).toBeLessThan(1e-3);
|
||
// 大腿 + 小腿长度守恒
|
||
expect(vLen(vSub(knee, hip)) + vLen(vSub(ankle, knee))).toBeGreaterThan(LEG_LENGTHS.thigh);
|
||
// 膝盖在髋→踝连线的 +Z 侧
|
||
const axis = vNorm(vSub(ankle, hip));
|
||
const rel = vSub(knee, hip);
|
||
const along = vDot(rel, axis);
|
||
const perpZ = rel[2] - axis[2] * along;
|
||
expect(perpZ).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('IK 目标不可达时腿伸直且不产生 NaN', () => {
|
||
const vmd = parseVmd(
|
||
restVmd([{ name: '左足IK', frame: 0, position: [0, 0, 50] }]),
|
||
);
|
||
const pose = solveFrame(vmd, 0, 'zMirrored', { 左: true, 右: false });
|
||
const hip = pose.get('左足')!.t;
|
||
const ankle = pose.get('左足首')!.t;
|
||
const knee = pose.get('左ひざ')!.t;
|
||
expect(vLen(vSub(ankle, hip))).toBeCloseTo(LEG_LENGTHS.thigh + LEG_LENGTHS.shin, 2);
|
||
expect(angleCos(hip, knee, ankle)).toBeLessThan(-0.999);
|
||
for (const value of [...hip, ...ankle, ...knee]) expect(Number.isFinite(value)).toBe(true);
|
||
});
|
||
|
||
it('IK 目标与髋重合时也不退化(不产生 NaN)', () => {
|
||
const vmd = parseVmd(
|
||
restVmd([{ name: '左足IK', frame: 0, position: [-0.95, -11.5, 0] }]),
|
||
);
|
||
const pose = solveFrame(vmd, 0, 'zMirrored', { 左: true, 右: false });
|
||
for (const bone of ['左足', '左ひざ', '左足首']) {
|
||
for (const value of pose.get(bone)!.t) expect(Number.isFinite(value)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('IK 通道没有位移时不做 IK 解算(走 FK)', () => {
|
||
const vmd = parseVmd(
|
||
buildVmd({
|
||
bones: [
|
||
{ name: 'センター', frame: 0 },
|
||
{ name: '左足IK', frame: 0 },
|
||
{ name: '左足', frame: 0, rotation: quatAxis('x', -60) },
|
||
],
|
||
}),
|
||
);
|
||
const { frames } = vmdToWorldFrames(vmd, { rotationMode: 'zMirrored', maxFrames: 1 });
|
||
const world = frames[0];
|
||
// FK 生效:膝盖不再位于静止位置
|
||
expect(world.get('leftleg')![1]).not.toBeCloseTo(REST_HEIGHTS.knee, 1);
|
||
// 踝仍在小腿末端(长度守恒)
|
||
const hip = world.get('leftupleg')!;
|
||
const knee = world.get('leftleg')!;
|
||
const ankle = world.get('leftfoot')!;
|
||
expect(vLen(vSub(knee, hip))).toBeCloseTo(LEG_LENGTHS.thigh, 2);
|
||
expect(vLen(vSub(ankle, knee))).toBeCloseTo(LEG_LENGTHS.shin, 2);
|
||
});
|
||
});
|
||
|
||
describe('手性转换(真实 VMD 对照实验的合成复现)', () => {
|
||
/**
|
||
* MMD 原生是左手系。真实数据里的对照实验(恋爱循环.vmd,腿部为烘焙 FK):
|
||
* 用 asIs 解释时"膝盖朝前"只占 5.9%,用 zMirrored 时占 94.1%。
|
||
* 这里用合成数据把同一结论固化成回归测试:
|
||
* 在 MMD 里把大腿绕 +X 转(动画师想让膝盖前顶),
|
||
* 转换到右手系后膝盖应当落在 +Z 侧。
|
||
*/
|
||
const thighBend = (mode: MmdRotationMode): number => {
|
||
const vmd = parseVmd(restVmd([{ name: '左足', frame: 0, rotation: quatAxis('x', 30) }]));
|
||
const pose = solveFrame(vmd, 0, mode, { 左: false, 右: false });
|
||
const hip = pose.get('左足')!.t;
|
||
const ankle = pose.get('左足首')!.t;
|
||
const knee = pose.get('左ひざ')!.t;
|
||
return knee[2] - (hip[2] + ((ankle[2] - hip[2]) * vLen(vSub(knee, hip))) / vLen(vSub(ankle, hip)));
|
||
};
|
||
|
||
it('zMirrored 把 MMD 的左手系数值正确搬到右手系(膝盖朝前)', () => {
|
||
expect(thighBend('zMirrored')).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('asIs 会得到镜像的结果(膝盖朝后)—— 因此不能作为默认', () => {
|
||
expect(thighBend('asIs')).toBeLessThan(0);
|
||
});
|
||
});
|
||
|
||
describe('端到端:合成 VMD → MediaPipe 33 点关键点序列', () => {
|
||
it('输出帧数、点数与关节角数量正确,且无 NaN / 越界', () => {
|
||
const vmd = parseVmd(
|
||
buildVmd({
|
||
bones: [
|
||
{ name: 'センター', frame: 0, position: [0, 0, 0] },
|
||
{ name: 'センター', frame: 60, position: [0, 3, 0] },
|
||
{ name: '左足IK', frame: 0, position: [0, 0.6, 0] },
|
||
{ name: '左足IK', frame: 60, position: [0, 0.6, 0] },
|
||
{ name: '上半身', frame: 0, rotation: quatAxis('y', 0) },
|
||
{ name: '上半身', frame: 60, rotation: quatAxis('y', 45) },
|
||
],
|
||
morphCount: 2,
|
||
}),
|
||
);
|
||
const { frames: world, fps } = vmdToWorldFrames(vmd, { maxFrames: 61 });
|
||
const frames = worldFramesToKeypointFrames(world, fps);
|
||
|
||
expect(fps).toBe(VMD_FPS);
|
||
expect(frames).toHaveLength(61);
|
||
for (const frame of frames) {
|
||
expect(frame.points).toHaveLength(KEYPOINT_COUNT);
|
||
expect(frame.angles).toHaveLength(ANGLE_TRIPLETS.length);
|
||
for (const point of frame.points) {
|
||
expect(point).toHaveLength(4);
|
||
expect(point.every(Number.isFinite)).toBe(true);
|
||
expect(point[0]).toBeGreaterThanOrEqual(0);
|
||
expect(point[0]).toBeLessThanOrEqual(1);
|
||
expect(point[1]).toBeGreaterThanOrEqual(0);
|
||
expect(point[1]).toBeLessThanOrEqual(1);
|
||
expect(point[2]).toBeGreaterThanOrEqual(-1);
|
||
expect(point[2]).toBeLessThanOrEqual(1);
|
||
expect([0, 0.5, 1]).toContain(point[3]);
|
||
}
|
||
for (const angle of frame.angles) {
|
||
expect(angle).toBeGreaterThanOrEqual(-1);
|
||
expect(angle).toBeLessThanOrEqual(1);
|
||
}
|
||
}
|
||
// 时间轴按 30fps 推进
|
||
expect(frames[1].timeMs).toBeCloseTo(1000 / 30, 3);
|
||
});
|
||
|
||
it('上半身旋转会改变输出(不是常数序列)', () => {
|
||
const vmd = parseVmd(
|
||
buildVmd({
|
||
bones: [
|
||
{ name: 'センター', frame: 0 },
|
||
{ name: '上半身', frame: 0, rotation: quatAxis('y', 0) },
|
||
{ name: '上半身', frame: 30, rotation: quatAxis('y', 90) },
|
||
],
|
||
}),
|
||
);
|
||
const { frames: world } = vmdToWorldFrames(vmd, { maxFrames: 31 });
|
||
const frames = worldFramesToKeypointFrames(world, VMD_FPS);
|
||
const first = frames[0].points[11][0];
|
||
const last = frames[30].points[11][0];
|
||
expect(Math.abs(last - first)).toBeGreaterThan(0.02);
|
||
});
|
||
});
|