/**
* Анимационный клип — ключевые кадры как данные (docs/plan.md, «анимация =
* данные, не кадры»). Клип = набор ключей {t, кости: rot/pos}; поза в момент
* t — линейная интерполяция между соседними ключами (по каждой кости и каналу
* отдельно; канал, отсутствующий в ключе, берётся из ближайшего ключа с ним).
* Валидация числовая: границы и монотонность ключей, для зацикленных клипов —
* непрерывность цикла (первый ключ = последнему).
*/
import type { Pose, VoxelRig } from './rig';
import { poseVoxels } from './rig';
import type { VoxelModel } from '../models/format';
import type { Vec3 } from './mat';
/** Кадр одной кости: поворот (рад) и/или сдвиг (воксельных единиц). */
export interface BoneFrame {
rot?: Vec3;
pos?: Vec3;
}
/** Ключевой кадр: момент t (сек) + каналы костей по именам. */
export interface ClipKey {
t: number;
bones: Record<string, BoneFrame>;
}
export interface AnimClip {
name: string;
/** Длительность цикла в секундах (> 0). */
duration: number;
/** Зацикленный клип: первый ключ на t=0 обязан совпадать с последним на t=duration. */
loop: boolean;
/** Ключи отсортированы по t строго возрастание. */
keys: ClipKey[];
}
const EPS = 1e-6;
/** Поза в момент t: линейная интерполяция каналов между соседними ключами. */
export function samplePose(clip: AnimClip, t: number): Pose {
const pose: Record<string, BoneFrame> = {};
if (clip.keys.length === 0) return pose;
const time = clip.loop
? ((t % clip.duration) + clip.duration) % clip.duration
: Math.min(clip.duration, Math.max(0, t));
// соседние ключи: keys[i].t ≤ time < keys[i+1].t (после последнего — последний)
let i = 0;
while (i + 1 < clip.keys.length && clip.keys[i + 1].t <= time) i++;
const a = clip.keys[i], b = clip.keys[Math.min(i + 1, clip.keys.length - 1)];
const span = b.t - a.t;
const k = span > EPS ? (time - a.t) / span : 0;
const names = new Set([...Object.keys(a.bones), ...Object.keys(b.bones)]);
for (const name of names) {
const fa = a.bones[name], fb = b.bones[name];
const rot = lerpVec(fa?.rot, fb?.rot, k);
const pos = lerpVec(fa?.pos, fb?.pos, k);
if (rot || pos) pose[name] = { ...(rot && { rot }), ...(pos && { pos }) };
}
return pose;
}
/** Линейная интерполяция вектора; отсутствующая сторона — вектор с другой стороны. */
function lerpVec(a: Vec3 | undefined, b: Vec3 | undefined, k: number): Vec3 | undefined {
if (!a && !b) return undefined;
const va = a ?? b!, vb = b ?? a!;
return [
va[0] + (vb[0] - va[0]) * k,
va[1] + (vb[1] - va[1]) * k,
va[2] + (vb[2] - va[2]) * k,
];
}
/** Список проблем клипа; пустой — клип корректен. */
export function validateClip(clip: AnimClip): string[] {
const problems: string[] = [];
if (clip.duration <= 0) problems.push(`длительность ${clip.duration} ≤ 0`);
if (clip.keys.length === 0) problems.push('нет ключевых кадров');
clip.keys.forEach((key, i) => {
if (key.t < 0 || key.t > clip.duration) problems.push(`ключ ${i}: t=${key.t} вне [0, ${clip.duration}]`);
if (i > 0 && key.t <= clip.keys[i - 1].t) problems.push(`ключ ${i}: t=${key.t} не больше предыдущего`);
});
if (clip.loop && clip.keys.length >= 2) {
const first = clip.keys[0], last = clip.keys[clip.keys.length - 1];
if (first.t !== 0) problems.push(`цикл: первый ключ на t=${first.t}, жду 0`);
if (last.t !== clip.duration) problems.push(`цикл: последний ключ на t=${last.t}, жду ${clip.duration}`);
for (const name of Object.keys(first.bones)) {
const fa = first.bones[name], fb = last.bones[name];
if (!fb) continue; // кость есть только в начале — поза берётся из ближайшего ключа
for (const ch of ['rot', 'pos'] as const) {
const va = fa[ch], vb = fb[ch];
if (!va || !vb) continue;
if (va.some((v, j) => Math.abs(v - vb[j]) > EPS)) {
problems.push(`цикл: кость «${name}» канал ${ch} рвётся на стыке (${va} → ${vb})`);
}
}
}
}
return problems;
}
/**
* Контакт стопы: минимальная мировая Y вокселей кости в фазе опоры.
* Числовая проверка «y кости ≈ 0 в фазе опоры» (docs/plan.md) — работает
* на связочной модели, где стопы стоят на y ≈ 0.
*/
export function footMinY(rig: VoxelRig, model: VoxelModel, clip: AnimClip, bone: string, t: number): number {
const posed = poseVoxels(rig, model, samplePose(clip, t));
const index = rig.bones.findIndex((b) => b.name === bone);
const ys = posed.filter((v) => rig.binding[v.index] === index).map((v) => v.pos[1]);
return ys.length ? Math.min(...ys) : NaN;
}