/**
* Рига воксельной модели — «анимация = данные» (docs/plan.md, направление v2).
* Кость = имя + родитель + pivot (точка вращения в связочном пространстве
* модели, где центр вокселя (x,y,z) — это (x+0.5, y+0.5, z+0.5)).
* Привязка вокселей к костям — детерминированная: у генератора части известны
* заранее (привязка передаётся явно), для чужих моделей — bindNearest
* (ближайший pivot, при равенстве меньший индекс кости).
* Деформация жёсткая на кость (кости не растягиваются — дистанции внутри
* кости сохраняются по построению).
*/
import { decodeModel } from '../models/format';
import type { VoxelModel } from '../models/format';
import { composeAffine, rotateAround, affinePoint } from './mat';
import type { Affine, Vec3 } from './mat';
export interface RigBone {
name: string;
/** Индекс кости-родителя; -1 у корня. Родитель всегда с меньшим индексом. */
parent: number;
pivot: Vec3;
}
export interface VoxelRig {
bones: RigBone[];
/** Воксель i (i = x + sx·(y + sy·z)) → индекс кости; -1 у пустых. */
binding: number[];
}
/** Поза: по имени кости — поворот (рад) и сдвиг (воксельных единиц). */
export interface Pose {
readonly [bone: string]: { rot?: Vec3; pos?: Vec3 } | undefined;
}
/** Деформированный воксель: позиция центра, цвет слота сцены и индекс в сетке. */
export interface PosedVoxel {
pos: Vec3;
color: string;
/** Индекс вокселя в сетке модели (i = x + sx·(y + sy·z)) — для привязки к кости. */
index: number;
}
/** Привязка «ближайший pivot» для моделей без явной разметки частей. */
export function bindNearest(bones: RigBone[], model: VoxelModel): number[] {
const grid = decodeModel(model);
const binding = new Array<number>(grid.data.length).fill(-1);
const centers = bones.map((b) => [b.pivot[0], b.pivot[1], b.pivot[2]]);
for (let i = 0; i < grid.data.length; i++) {
if (grid.data[i] === 0) continue;
const x = i % grid.sx, y = Math.floor(i / grid.sx) % grid.sy, z = Math.floor(i / (grid.sx * grid.sy));
const c = [x + 0.5, y + 0.5, z + 0.5];
let best = 0, bestD = Infinity;
for (let b = 0; b < centers.length; b++) {
const d = (centers[b][0] - c[0]) ** 2 + (centers[b][1] - c[1]) ** 2 + (centers[b][2] - c[2]) ** 2;
if (d < bestD) { bestD = d; best = b; } // при равенстве остаётся меньший индекс
}
binding[i] = best;
}
return binding;
}
/** Рига из костей: привязка передаётся явно (генератор знает части) или по ближайшему pivot. */
export function makeRig(bones: RigBone[], model: VoxelModel, binding?: number[]): VoxelRig {
return { bones, binding: binding ?? bindNearest(bones, model) };
}
/** Список проблем риги (числовая валидация агентом); пустой — рига корректна. */
export function validateRig(rig: VoxelRig, model: VoxelModel): string[] {
const problems: string[] = [];
const { bones, binding } = rig;
if (bones.length === 0) problems.push('нет костей');
const seen = new Set<string>();
bones.forEach((b, i) => {
if (!b.name) problems.push(`кость ${i}: пустое имя`);
if (seen.has(b.name)) problems.push(`кость ${i}: имя «${b.name}» повторяется`);
seen.add(b.name);
if (b.parent < -1 || b.parent >= bones.length) problems.push(`кость «${b.name}»: родитель ${b.parent} вне риги`);
else if (b.parent >= i) problems.push(`кость «${b.name}»: родитель ${b.parent} не раньше самой кости (цикл)`);
});
const grid = decodeModel(model);
if (binding.length !== grid.data.length) {
problems.push(`привязка ${binding.length} байт, а вокселей ${grid.data.length}`);
return problems;
}
for (let i = 0; i < binding.length; i++) {
const empty = grid.data[i] === 0;
if (empty && binding[i] !== -1) problems.push(`воксель ${i} пуст, но привязан к кости ${binding[i]}`);
if (!empty && binding[i] === -1) problems.push(`воксель ${i} занят, но не привязан ни к какой кости`);
if (!empty && (binding[i] < 0 || binding[i] >= bones.length)) {
problems.push(`воксель ${i} привязан к кости ${binding[i]} вне риги`);
break;
}
}
return problems;
}
/** Мир-трансформы костей (связочное пространство → мир) по цепочкам родителей. */
export function boneWorlds(rig: VoxelRig, pose: Pose): Affine[] {
// родители всегда раньше детей по индексу (validateRig) — считаем итеративно
const worlds: Affine[] = [];
rig.bones.forEach((bone, i) => {
const p = pose[bone.name];
const local = rotateAround(p?.rot ?? [0, 0, 0], bone.pivot, p?.pos ?? [0, 0, 0]);
worlds[i] = bone.parent >= 0 && bone.parent < i
? composeAffine(worlds[bone.parent], local)
: local;
});
return worlds;
}
/** Деформированные воксели модели в позе (центры в мировых координатах). */
export function poseVoxels(rig: VoxelRig, model: VoxelModel, pose: Pose): PosedVoxel[] {
const grid = decodeModel(model);
const worlds = boneWorlds(rig, pose);
const out: PosedVoxel[] = [];
for (let i = 0; i < grid.data.length; i++) {
const slot = grid.data[i];
if (slot === 0) continue;
const x = i % grid.sx, y = Math.floor(i / grid.sx) % grid.sy, z = Math.floor(i / (grid.sx * grid.sy));
const pos = affinePoint(worlds[rig.binding[i]], [x + 0.5, y + 0.5, z + 0.5]);
out.push({ pos, color: model.palette[String(slot)], index: i });
}
return out;
}