/** * 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; }