/**
* ModelViewer — просмотрщик воксельных моделей для дев-панели (трек 28).
* Орбитальная камера поверх VoxelRenderer (тот же текстурный слой/тайли),
* воспроизведение клипов риги, авто-поворот (turntable), статы модели.
* Игровая дев-панель — надстройка игры: движок даёт класс, панель — UI.
* Чистая математика орбиты и статов — отдельные функции (урок №3).
*/
import type { VoxelModel } from '../models/format';
import { decodeModel } from '../models/format';
import type { VoxelRig, Pose } from '../animation/rig';
import { poseVoxels } from '../animation/rig';
import type { AnimClip } from '../animation/clip';
import { samplePose } from '../animation/clip';
import type { Vec3 } from '../animation/mat';
import { VoxelRenderer, type CloudTiles } from './voxelRenderer';
import type { VoxelCloud } from './voxelRenderer';
/** Статы модели для панели (числовой снапшот агентом). */
export interface ModelStats {
/** Размер сетки [sx, sy, sz]. */
size: [number, number, number];
/** Занятых вокселей. */
voxels: number;
/** Костей риги (0 — модель без риги). */
bones: number;
/** Число тайлей текстурного слоя (0 — без слоя tex). */
tiles: number;
}
/** Сферическая орбита камеры: yaw/pitch вокруг target на дистанции. */
export function orbitPosition(
yaw: number, pitch: number, dist: number, target: readonly [number, number, number],
): Vec3 {
const cp = Math.cos(pitch);
return [
target[0] + dist * cp * Math.cos(yaw),
target[1] + dist * Math.sin(pitch),
target[2] + dist * cp * Math.sin(yaw),
];
}
/** Центр bbox занятых вокселей (ось вращения вьювера — модель крутится
* вокруг своей середины, а не вокруг угла сетки). Пустая модель — центр
* размера сетки. */
export function modelCenter(model: VoxelModel): [number, number, number] {
const g = decodeModel(model);
let min = [Infinity, Infinity, Infinity];
let max = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < g.data.length; i++) {
if (g.data[i] === 0) continue;
const x = i % g.sx, y = Math.floor(i / g.sx) % g.sy, z = Math.floor(i / (g.sx * g.sy));
min = [Math.min(min[0], x), Math.min(min[1], y), Math.min(min[2], z)];
max = [Math.max(max[0], x), Math.max(max[1], y), Math.max(max[2], z)];
}
if (min[0] === Infinity) return [model.size[0] / 2, model.size[1] / 2, model.size[2] / 2];
return [(min[0] + max[0] + 1) / 2, (min[1] + max[1] + 1) / 2, (min[2] + max[2] + 1) / 2];
}
/** Числовые статы модели (занятые воксели — по декодированной сетке). */
export function modelStats(model: VoxelModel, rig?: VoxelRig): ModelStats {
const grid = decodeModel(model);
let voxels = 0;
for (let i = 0; i < grid.data.length; i++) if (grid.data[i] !== 0) voxels++;
return {
size: [...model.size] as [number, number, number],
voxels,
bones: rig?.bones.length ?? 0,
tiles: model.tiles ? Object.keys(model.tiles).length : 0,
};
}
export interface ModelViewerOptions {
/** размер бэкинг-стора (по умолчанию 480×360) */
width?: number;
height?: number;
/** половина высоты орто-фрустума (зум), в воксельных единицах */
viewSize?: number;
/** скорость авто-поворота (рад/с); 0 — без вращения */
spin?: number;
}
/** Просмотрщик: модель + клип риги, орбита мышью, статы. */
export class ModelViewer {
readonly renderer: VoxelRenderer;
private readonly canvas: HTMLCanvasElement;
private readonly viewSize: number;
private spinSpeed: number;
private cloud: VoxelCloud | null = null;
private model: VoxelModel | null = null;
private rig: VoxelRig | null = null;
private clip: AnimClip | null = null;
private tiles: CloudTiles | null = null;
private center: [number, number, number] = [0, 4, 0]; // ось орбиты
private time = 0;
private playing = true;
private spin = 0; // накопленный авто-поворот (рад)
// орбита: yaw/pitch — мышью или методами (гейт), zoom — множитель viewSize
private yaw = Math.PI / 4;
private pitch = 0.5;
private zoom = 1;
private drag: { x: number; y: number } | null = null;
private disposed = false;
constructor(canvas: HTMLCanvasElement, opts: ModelViewerOptions = {}) {
this.canvas = canvas;
const width = opts.width ?? 480;
const height = opts.height ?? 360;
this.viewSize = opts.viewSize ?? 16;
this.spinSpeed = opts.spin ?? 0;
this.renderer = new VoxelRenderer(canvas, { width, height, viewSize: this.viewSize });
canvas.addEventListener('pointerdown', this.onDown);
canvas.addEventListener('pointermove', this.onMove);
canvas.addEventListener('pointerup', this.onUp);
canvas.addEventListener('wheel', this.onWheel, { passive: false });
this.placeCamera();
}
/**
* Показать модель. rig+clip — анимация (поза семплится в render);
* tiles — текстурный слой (`resolveTiles(model)`), палитра — в модели.
*/
setModel(model: VoxelModel, opts: { rig?: VoxelRig; clip?: AnimClip; tiles?: CloudTiles } = {}): void {
this.model = model;
this.rig = opts.rig ?? null;
this.clip = opts.clip ?? null;
this.tiles = opts.tiles ?? null;
this.center = modelCenter(model);
this.time = 0;
this.cloud?.dispose();
const pose: Pose = {};
this.cloud = this.renderer.addVoxelCloud(poseVoxels(this.rig ?? dummyRig(model), model, pose), this.tiles ?? undefined);
this.refreshCloud();
this.placeCamera();
}
/** Пауза/продолжение клипа. */
play(on: boolean): void {
this.playing = on;
}
/** Авто-поворот (turntable), рад/с; 0 — выключить. */
setSpin(radPerSec: number): void {
this.spinSpeed = radPerSec;
}
/** Прыжок клипа в момент t (сек). */
setTime(t: number): void {
this.time = t;
this.refreshCloud();
}
/** Орбита программой (гейтом): угол поворота и возвышения (рад). */
setOrbit(yaw: number, pitch: number): void {
this.yaw = yaw;
this.pitch = Math.min(Math.PI / 2 - 0.05, Math.max(0.05, pitch));
this.placeCamera();
}
/** Зум: доля от базового viewSize (0.3..3). */
setZoom(k: number): void {
this.zoom = Math.min(3, Math.max(0.3, k));
this.placeCamera();
}
/** Один кадр: авто-поворот, семпл позы клипа, рендер. */
render(dt: number): void {
if (this.disposed) return;
if (this.spinSpeed) {
this.spin += this.spinSpeed * dt;
this.placeCamera();
}
if (this.clip) {
if (this.playing) this.time = (this.time + dt) % this.clip.duration;
this.refreshCloud();
}
this.renderer.render();
}
/** Статы текущей модели (панель/мост). */
stats(): ModelStats | null {
return this.model ? modelStats(this.model, this.rig ?? undefined) : null;
}
dispose(): void {
this.disposed = true;
this.cloud?.dispose();
this.canvas.removeEventListener('pointerdown', this.onDown);
this.canvas.removeEventListener('pointermove', this.onMove);
this.canvas.removeEventListener('pointerup', this.onUp);
this.canvas.removeEventListener('wheel', this.onWheel);
}
/** Пересемплить позу клипа в облако (облако есть — модель стоит). */
private refreshCloud(): void {
if (!this.cloud || !this.model) return;
const pose = this.clip ? samplePose(this.clip, this.time) : {};
this.cloud.update(poseVoxels(this.rig ?? dummyRig(this.model), this.model, pose));
}
/** Камера на сферической орбите вокруг центра модели. */
private placeCamera(): void {
const model = this.model;
const target: [number, number, number] = model ? this.center : [0, 4, 0];
const span = model ? Math.max(...model.size) : 10;
const dist = Math.max(span * 1.6, 10) * this.zoom;
const pos = orbitPosition(this.yaw + this.spin, this.pitch, dist, target);
const cam = this.renderer.camera;
cam.position.set(...pos);
cam.lookAt(target[0], target[1], target[2]);
cam.updateMatrixWorld();
}
private onDown = (e: PointerEvent): void => {
this.drag = { x: e.clientX, y: e.clientY };
this.canvas.setPointerCapture(e.pointerId);
};
private onMove = (e: PointerEvent): void => {
if (!this.drag) return;
this.yaw -= (e.clientX - this.drag.x) * 0.01;
this.pitch = Math.min(Math.PI / 2 - 0.05, Math.max(0.05, this.pitch + (e.clientY - this.drag.y) * 0.01));
this.drag = { x: e.clientX, y: e.clientY };
this.placeCamera();
};
private onUp = (): void => {
this.drag = null;
};
private onWheel = (e: WheelEvent): void => {
e.preventDefault();
this.setZoom(this.zoom * (e.deltaY > 0 ? 1.1 : 0.9));
};
}
/** Рига-пустышка: модели без риги — один root, все воксели к нему. */
function dummyRig(model: VoxelModel): VoxelRig {
const count = model.size[0] * model.size[1] * model.size[2];
return { bones: [{ name: 'root', parent: -1, pivot: [0, 0, 0] }], binding: new Array(count).fill(0) };
}