Files
opendance-web/web-server/src/preprocess/math3d.ts
T
zijunchiang bba5bcf59e feat(web): 端口分离 + VMD 动作支持;契约升至 0.2.0
需求 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 越界(测试数据已从数据库清理)。
2026-09-15 08:47:37 +08:00

137 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type Vec3 = [number, number, number];
/** 3×3 行主序矩阵 */
export type Mat3 = [number, number, number, number, number, number, number, number, number];
export const IDENTITY3: Mat3 = [1, 0, 0, 0, 1, 0, 0, 0, 1];
export function vAdd(a: Vec3, b: Vec3): Vec3 {
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
}
export function vSub(a: Vec3, b: Vec3): Vec3 {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
export function vScale(a: Vec3, s: number): Vec3 {
return [a[0] * s, a[1] * s, a[2] * s];
}
export function vMid(a: Vec3, b: Vec3): Vec3 {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];
}
export function vLen(a: Vec3): number {
return Math.hypot(a[0], a[1], a[2]);
}
export function vNorm(a: Vec3): Vec3 {
const len = vLen(a);
if (len < 1e-9) return [0, 0, 0];
return [a[0] / len, a[1] / len, a[2] / len];
}
export function vDot(a: Vec3, b: Vec3): number {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
/** 向量 ba 与 bc 夹角的余弦,落在 [-1, 1] —— 契约 skeleton-topology.md §5 */
export function angleCos(a: Vec3, b: Vec3, c: Vec3): number {
const ba = vSub(a, b);
const bc = vSub(c, b);
const denom = vLen(ba) * vLen(bc);
if (denom < 1e-9) return 0;
return Math.max(-1, Math.min(1, vDot(ba, bc) / denom));
}
export function matMul(a: Mat3, b: Mat3): Mat3 {
const out = new Array<number>(9);
for (let r = 0; r < 3; r += 1) {
for (let c = 0; c < 3; c += 1) {
out[r * 3 + c] = a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c];
}
}
return out as Mat3;
}
/**
* 四元数 (x, y, z, w) → 3×3 行主序旋转矩阵。
* 输入会自动归一化;零四元数视为单位四元数。
*/
export function quatToMat3(q: readonly [number, number, number, number]): Mat3 {
let [x, y, z, w] = q;
const len = Math.hypot(x, y, z, w);
if (len < 1e-9) return IDENTITY3;
x /= len;
y /= len;
z /= len;
w /= len;
return [
1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w),
2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w),
2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y),
];
}
export function matTranspose(m: Mat3): Mat3 {
return [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]];
}
export function vCross(a: Vec3, b: Vec3): Vec3 {
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
}
export function matVec(m: Mat3, v: Vec3): Vec3 {
return [
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
];
}
const DEG2RAD = Math.PI / 180;
/** 绕单轴旋转矩阵,axis ∈ {X, Y, Z} */
export function rotationMatrix(axis: string, degrees: number): Mat3 {
const rad = degrees * DEG2RAD;
const c = Math.cos(rad);
const s = Math.sin(rad);
switch (axis.toUpperCase()) {
case 'X':
return [1, 0, 0, 0, c, -s, 0, s, c];
case 'Y':
return [c, 0, s, 0, 1, 0, -s, 0, c];
case 'Z':
return [c, -s, 0, s, c, 0, 0, 0, 1];
default:
return IDENTITY3;
}
}
/**
* 由通道序列合成局部旋转矩阵。
* BVH 约定:按通道声明顺序依次左乘,即 R = R1 × R2 × R3(内旋)。
*/
export function composeRotation(channels: ReadonlyArray<{ axis: string; value: number }>): Mat3 {
let out: Mat3 = IDENTITY3;
for (const { axis, value } of channels) {
out = matMul(out, rotationMatrix(axis, value));
}
return out;
}
/** 世界变换:旋转 + 平移 */
export interface Transform {
r: Mat3;
t: Vec3;
}
export const IDENTITY_TRANSFORM: Transform = { r: IDENTITY3, t: [0, 0, 0] };
export function applyTransform(parent: Transform, offset: Vec3, localR: Mat3, extraT?: Vec3): Transform {
return {
r: matMul(parent.r, localR),
t: vAdd(parent.t, matVec(parent.r, vAdd(offset, extraT ?? [0, 0, 0]))),
};
}