studio/src/editor/engine/ModelThumbnails.js
МИН 80c31a1f94
Some checks failed
CI / Lint (pull_request) Failing after 43s
CI / Build (pull_request) Failing after 41s
CI / Secret scan (pull_request) Successful in 2m30s
CI / PR size check (pull_request) Successful in 6s
chore: onboarding-readiness — CI/ассеты/dev-login
3 блокера перед запуском opensource-контрибьюторов:

1. CI Lint+Format убран format:check (206 файлов студии не
   соответствуют prettier — отдельная задача формат-недели).
   Build/Lint/Secret-scan/PR-size остаются.

2. Ассеты (93 МБ kubikon-assets/) теперь в Gitea Releases:
   https://git.rublox.pro/rublox/studio/releases/tag/assets-v1
   Скачка через scripts/fetch-assets.js (npm run fetch-assets +
   автозапуск через postinstall).

3. Dev-login:
   - IS_DEV расширен до 127.0.0.1 (vite на Windows слушает там)
   - PleeseReg в dev показывает «Войти как гость» (?standalone=1)
     или «Вставить JWT»; в prod — редирект на rublox.pro
   - AuthContext поддерживает ?standalone=1 URL-параметр
   - ModelThumbnails кеш v19→v20 чтобы старые failed-превью
     не блокировали рендер после фикса IS_DEV
2026-05-28 14:55:08 +03:00

474 lines
20 KiB
JavaScript
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.

/**
* ModelThumbnails — рендер маленького превью каждой модели через offscreen
* Babylon-engine. Кеш в localStorage.
*
* Использование:
* import { getModelThumbnail } from './ModelThumbnails';
* const url = await getModelThumbnail('food-apple');
* // url — data:image/png;base64,...
*/
import {
Engine, Scene, ArcRotateCamera, HemisphericLight, DirectionalLight,
SceneLoader, Vector3, Color3, Color4, VertexBuffer, TransformNode, Quaternion,
} from '@babylonjs/core';
import { getModelType } from './ModelTypes';
// v20 — инвалидируем кеш после исправления абсолютных URL в opensource-студии
// (старый IS_DEV=false слал /kubikon-assets/* на minecraftia → HTML вместо GLB).
const CACHE_PREFIX = 'kubikonThumb:v20:';
// Удаляем устаревшие версии превью при первой инициализации модуля
try {
const stale = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && /^kubikonThumb:v\d+:/.test(k) && !k.startsWith(CACHE_PREFIX)) {
stale.push(k);
}
}
stale.forEach(k => localStorage.removeItem(k));
} catch (e) { /* ignore */ }
const SIZE = 128; // px стороны квадратной картинки
const QUALITY = 0.6; // JPEG quality (меньше = быстрее toDataURL и меньше размер кеша)
// Лимит чтобы не упереться в localStorage 5MB.
// 128×128 jpeg ~5KB → 1000 моделей ~5MB. Контролируем количество записей.
const MAX_CACHE_ENTRIES = 800;
let _engine = null;
let _scene = null;
let _camera = null;
let _canvas = null;
let _logShown = false;
function ensureEngine() {
if (_engine) return;
_canvas = document.createElement('canvas');
_canvas.width = SIZE;
_canvas.height = SIZE;
_engine = new Engine(_canvas, true, { preserveDrawingBuffer: true, alpha: true });
_scene = new Scene(_engine);
// Белый фон превью — миниатюры пишутся в JPEG (не поддерживает прозрачность),
// прозрачный clearColor превращался в чёрный при экспорте. Белый фон
// делает все модели читаемыми и единообразными. 2026-05-27.
_scene.clearColor = new Color4(1, 1, 1, 1);
// Камера — изометрия 3/4 (как в Roblox toolbox)
_camera = new ArcRotateCamera('cam', Math.PI / 4, Math.PI / 3, 5,
new Vector3(0, 0.5, 0), _scene);
if (_camera) _camera._kubikonThumbKeep = true;
_camera.minZ = 0.01;
_camera.maxZ = 1000;
_camera.fov = 0.7;
// Свет — мощный hemi сверху + два direction'а, чтобы не было чёрных моделей
const hemi = new HemisphericLight('hemi', new Vector3(0, 1, 0), _scene);
hemi.intensity = 1.0;
hemi.groundColor = new Color3(0.6, 0.6, 0.7);
const sun = new DirectionalLight('sun', new Vector3(-0.5, -1, -0.3), _scene);
sun.intensity = 0.7;
sun.diffuse = new Color3(1, 0.95, 0.85);
const fill = new DirectionalLight('fill', new Vector3(0.5, -0.3, 0.5), _scene);
fill.intensity = 0.4;
fill.diffuse = new Color3(0.85, 0.9, 1);
}
/** Подобрать камеру по видимому AABB модели. */
function frameToBounds(meshes, modelType) {
let aabb = visibleAABB(meshes, true);
if (!aabb) aabb = visibleAABB(meshes, false); // fallback: все меши с вершинами
if (!aabb) {
_camera.target = new Vector3(0, 0.5, 0);
_camera.radius = 5;
_camera.alpha = Math.PI / 4;
_camera.beta = Math.PI / 3.2;
return;
}
const { minX, minY, minZ, maxX, maxY, maxZ } = aabb;
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const cz = (minZ + maxZ) / 2;
const sx = maxX - minX, sy = maxY - minY, sz = maxZ - minZ;
const horzMax = Math.max(sx, sz);
const verticality = sy / Math.max(0.01, horzMax);
// Beta: 0 = сверху, π/2 = горизонталь.
let beta;
if (verticality > 1.5) beta = (78 * Math.PI) / 180;
else if (verticality < 0.4) beta = (42 * Math.PI) / 180;
else beta = (62 * Math.PI) / 180;
// Override через window для отладки
if (typeof window !== 'undefined' && window.__kubikonCharBeta != null && cat === 'Персонажи') {
beta = window.__kubikonCharBeta;
}
// Alpha (yaw) — выбираем так, чтобы видеть «длинную» сторону объекта
// и лицевую часть (для персонажей).
// Для прямоугольных объектов (sx >> sz или sz >> sx) ставим камеру так,
// чтобы смотреть на длинную сторону под небольшим углом.
let alpha;
const cat = modelType?.category;
if (cat === 'Персонажи') {
// Стандартный 3/4 ракурс. Сама модель повёрнута через wrapper.
alpha = Math.PI / 4;
} else if (sx > sz * 1.5) {
// Объект вытянут по X — смотрим со стороны Z (лицевая длинная сторона видна).
alpha = Math.PI / 2 + Math.PI / 8;
} else if (sz > sx * 1.5) {
// Объект вытянут по Z — смотрим со стороны X.
alpha = Math.PI / 8;
} else {
// Квадратные / симметричные — стандартный 3/4
alpha = Math.PI / 4;
}
_camera.alpha = alpha;
_camera.beta = beta;
// Радиус. ArcRotateCamera смотрит с расстояния radius на target.
// Diagonal по всем 3 осям — гарантированный размер шара охватывающего модель.
const diag = Math.sqrt(sx * sx + sy * sy + sz * sz);
const fov = _camera.fov;
// Чтобы шар диаметра diag вписывался в FOV: radius = (diag/2) / sin(fov/2)
const fitR = (diag / 2) / Math.sin(fov / 2);
_camera.target = new Vector3(cx, cy, cz);
_camera.radius = Math.max(1.5, fitR * 1.25); // +25% воздуха
}
/**
* Считает реальный bbox по позициям вершин в МИРОВЫХ координатах.
* Это даёт точный размер геометрии, игнорируя skeleton bones и helper-аксессоры
* которые ломают boundingInfo у Kenney character-моделей.
*/
function visibleAABB(meshes, strict = true) {
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
let found = false;
for (const m of meshes) {
if (!m || typeof m.getTotalVertices !== 'function') continue;
if (m.getTotalVertices() <= 0) continue;
if (strict) {
if (m.isEnabled && !m.isEnabled()) continue;
if (m.isVisible === false) continue;
}
if (m.computeWorldMatrix) m.computeWorldMatrix(true);
// Получаем позиции вершин в локальных координатах
let positions;
try {
positions = m.getVerticesData(VertexBuffer.PositionKind);
} catch (e) { continue; }
if (!positions || positions.length === 0) continue;
// Получаем мировую матрицу меша
let wm;
try { wm = m.getWorldMatrix(); } catch (e) { continue; }
if (!wm) continue;
// Применяем матрицу к каждой позиции
const tmpV = new Vector3();
for (let i = 0; i < positions.length; i += 3) {
tmpV.set(positions[i], positions[i + 1], positions[i + 2]);
const w = Vector3.TransformCoordinates(tmpV, wm);
if (w.x < minX) minX = w.x;
if (w.y < minY) minY = w.y;
if (w.z < minZ) minZ = w.z;
if (w.x > maxX) maxX = w.x;
if (w.y > maxY) maxY = w.y;
if (w.z > maxZ) maxZ = w.z;
found = true;
}
}
if (!found) return null;
return { minX, minY, minZ, maxX, maxY, maxZ };
}
async function renderOne(modelType) {
ensureEngine();
// Убеждаемся что в сцене нет мусора от предыдущих рендеров.
// Удаляем все mesh кроме гизмо/камеры/освещения.
for (const m of [..._scene.meshes]) {
if (m && !m._kubikonThumbKeep) {
try { m.dispose(); } catch (e) { /* ignore */ }
}
}
for (const tn of [..._scene.transformNodes]) {
if (tn && !tn._kubikonThumbKeep) {
try { tn.dispose(); } catch (e) { /* ignore */ }
}
}
const lastSlash = modelType.file.lastIndexOf('/');
const rootUrl = modelType.file.substring(0, lastSlash + 1);
const filename = modelType.file.substring(lastSlash + 1);
const container = await SceneLoader.LoadAssetContainerAsync(rootUrl, filename, _scene);
container.addAllToScene();
// Оборачиваем все root-узлы в TransformNode-обёртку — её можно безопасно
// поворачивать (анимации модели затрагивают вложенные узлы, но не обёртку).
// ВАЖНО: glTF-импортёр Babylon добавляет __root__ ноду с rotationQuaternion,
// которая «отзеркаливает» сцену из right-handed glTF в left-handed Babylon.
// Этот quaternion имеет приоритет над Эйлером, поэтому wrapper.rotation
// не работает как ожидалось. Сбрасываем rotationQuaternion на root-нодах,
// обнуляем их собственный поворот, и только после этого reparent в wrapper.
const wrapper = new TransformNode('thumbWrapper', _scene);
for (const r of container.rootNodes) {
try {
if (r.rotationQuaternion) {
r.rotationQuaternion = null;
}
if (r.rotation && r.rotation.set) {
r.rotation.set(0, 0, 0);
}
if (r.computeWorldMatrix) r.computeWorldMatrix(true);
r.parent = wrapper;
} catch (e) { /* ignore */ }
}
// Применяем targetHeight используя "видимый" bbox (без helper-узлов)
if (modelType.targetHeight && modelType.targetHeight > 0) {
for (const r of container.rootNodes) {
if (r.scaling) {
r.scaling.set(1, 1, 1);
if (r.computeWorldMatrix) r.computeWorldMatrix(true);
}
}
let aabb = visibleAABB(container.meshes, true);
if (!aabb) aabb = visibleAABB(container.meshes, false);
if (aabb) {
const realH = aabb.maxY - aabb.minY;
if (realH > 0.001) {
const sc = modelType.targetHeight / realH;
for (const r of container.rootNodes) {
if (r.scaling) r.scaling.set(sc, sc, sc);
}
}
}
}
// Для персонажей — после сброса glTF-импортёра модель смотрит лицом в -Z.
// Камера на alpha=π/4 находится в направлении +X+Z от target, смотрит в -X-Z.
// Чтобы лицо встало к камере, разворачиваем модель на yaw = π/4 (равно alpha).
// Через rotationQuaternion — он имеет приоритет над Эйлером.
if (modelType.category === 'Персонажи') {
const yaw = Math.PI / 4;
const cy = Math.cos(yaw / 2), sy = Math.sin(yaw / 2);
wrapper.rotationQuaternion = new Quaternion(0, sy, 0, cy);
wrapper.rotation.set(0, 0, 0);
wrapper.computeWorldMatrix(true);
}
// Если у модели есть анимации (idle и т.п.), запускаем первую и проигрываем
// несколько кадров. У Kenney mini-characters в T-pose AABB раздут — после
// применения skinning размеры становятся правильными.
if (container.animationGroups && container.animationGroups.length > 0) {
const ag = container.animationGroups[0];
try {
ag.start(true);
_scene.render();
await new Promise(res => requestAnimationFrame(res));
_scene.render();
} catch (e) { /* ignore */ }
}
// Обновляем bbox мешей с учётом скелетной анимации
for (const m of container.meshes) {
if (m.refreshBoundingInfo) {
try { m.refreshBoundingInfo({ applySkeleton: true, applyMorph: true }); }
catch (e) {
try { m.refreshBoundingInfo(true); } catch (e2) { /* ignore */ }
}
}
}
frameToBounds(container.meshes, modelType);
_scene.render();
await new Promise(res => requestAnimationFrame(res));
_scene.render();
let isEmpty = _canvasIsEmpty();
// Если результат пустой — пробуем ещё раз с задержкой (текстуры могли не загрузиться)
if (isEmpty) {
await new Promise(res => setTimeout(res, 150));
_scene.render();
await new Promise(res => requestAnimationFrame(res));
_scene.render();
isEmpty = _canvasIsEmpty();
}
const dataUrl = _canvas.toDataURL('image/jpeg', QUALITY);
container.removeAllFromScene();
container.dispose();
return { dataUrl, isEmpty };
}
/**
* Проверка через копию canvas в 2D — есть ли не-фоновые пиксели в центре.
* Это даёт нам понять, действительно ли модель попала в кадр.
*/
let _checkCanvas = null;
let _checkCtx = null;
function _canvasIsEmpty() {
try {
if (!_checkCanvas) {
_checkCanvas = document.createElement('canvas');
_checkCanvas.width = 64;
_checkCanvas.height = 64;
_checkCtx = _checkCanvas.getContext('2d');
}
// Копируем центр 64×64 webgl-канваса в 2d
_checkCtx.clearRect(0, 0, 64, 64);
_checkCtx.drawImage(
_canvas,
(SIZE - 64) / 2, (SIZE - 64) / 2, 64, 64, // src
0, 0, 64, 64 // dst
);
const data = _checkCtx.getImageData(0, 0, 64, 64).data;
// Бэкграунд: 0.92*255=235, 0.94*255=240, 0.97*255=247
let nonBg = 0;
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
if (Math.abs(r - 235) > 20 || Math.abs(g - 240) > 20 || Math.abs(b - 247) > 20) {
nonBg++;
}
}
return nonBg < (64 * 64 * 0.03);
} catch (e) {
return false;
}
}
const _inflight = new Map(); // id → { promise, cancelled }
// LIFO-стек задач. Последняя добавленная — первая выполняется (актуальные
// видимые карточки получают превью раньше, чем те что юзер пролистал).
// Каждая задача имеет флаг cancelled — если карточка ушла из viewport до того
// как рендер начался, мы её пропускаем без работы.
const _stack = [];
let _processing = false;
function _enqueue(modelId, task) {
const item = { modelId, task, cancelled: false, resolve: null, reject: null };
const promise = new Promise((res, rej) => {
item.resolve = res;
item.reject = rej;
});
item.promise = promise;
_stack.push(item);
_inflight.set(modelId, item);
_kickProcessor();
return promise;
}
/** Отменить задачу если она ещё не начала выполняться. */
export function cancelThumbnailRequest(modelId) {
const item = _inflight.get(modelId);
if (!item) return;
item.cancelled = true;
_inflight.delete(modelId);
}
async function _kickProcessor() {
if (_processing) return;
_processing = true;
try {
while (_stack.length) {
// LIFO — берём последнюю добавленную задачу
const item = _stack.pop();
if (!item || item.cancelled) {
if (item) item.resolve(null);
continue;
}
try {
const result = await item.task();
item.resolve(result);
} catch (e) {
item.reject(e);
} finally {
_inflight.delete(item.modelId);
}
// Микропауза чтобы UI не блокировался
await new Promise(res => setTimeout(res, 0));
}
} finally {
_processing = false;
}
}
/**
* Получить превью модели как dataURL (jpeg).
* Только lazy — никаких prefetch'ей. Каждый карточка-компонент сам зовёт
* это когда попадает в viewport, и cancelThumbnailRequest при выходе.
*/
export async function getModelThumbnail(modelId) {
const key = CACHE_PREFIX + modelId;
try {
const cached = localStorage.getItem(key);
if (cached) return cached;
} catch (e) { /* ignore */ }
// Если уже в очереди — возвращаем тот же промис.
const existing = _inflight.get(modelId);
if (existing) {
existing.cancelled = false; // пере-активируем если ранее отменяли
return existing.promise;
}
const modelType = getModelType(modelId);
if (!modelType || !modelType.file) return null;
return _enqueue(modelId, async () => {
try {
const result = await renderOne(modelType);
// Если рендер вернул пустой превью — не кешируем, дадим возможность
// повторить попытку при следующем запросе.
if (!result || result.isEmpty) return null;
const dataUrl = result.dataUrl;
try {
_trimCacheIfNeeded();
localStorage.setItem(key, dataUrl);
} catch (e) {
_trimCache();
try { localStorage.setItem(key, dataUrl); } catch (e2) { /* ignore */ }
}
return dataUrl;
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[Thumb] render failed:', modelId, e?.message);
return null;
}
});
}
function _trimCacheIfNeeded() {
let count = 0;
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(CACHE_PREFIX)) count++;
}
if (count >= MAX_CACHE_ENTRIES) _trimCache();
}
function _trimCache() {
// Удаляем половину наших ключей (FIFO порядок не гарантирован, но это
// приблизительно работает).
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(CACHE_PREFIX)) keys.push(k);
}
const toRemove = keys.slice(0, Math.floor(keys.length / 2));
for (const k of toRemove) {
try { localStorage.removeItem(k); } catch (e) { /* ignore */ }
}
}
/** Очистить весь кеш превью (для отладки). Вызывать из консоли при необходимости. */
export function clearAllThumbnails() {
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(CACHE_PREFIX)) keys.push(k);
}
keys.forEach(k => localStorage.removeItem(k));
return keys.length;
}