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
474 lines
20 KiB
JavaScript
474 lines
20 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|