import React, { useEffect, useRef, useState, useCallback } from 'react'; import cl from './ModelEditorScreen.module.css'; import Icon from './Icon'; import { VoxelModelScene, ALLOWED_GRID_SIZES, VOXEL_TEXTURES } from './engine/VoxelModelScene'; import { createUserModel, updateUserModel, getUserModel, publishUserModel, unpublishUserModel, } from '../api/Kubikon3DService'; import ModelSaveModal from './ModelSaveModal'; /** Debounce-пауза перед автосейвом после последнего изменения (мс). */ const AUTOSAVE_DEBOUNCE_MS = 2500; /** * ModelEditorScreen — полноэкранный режим создания пользовательской модели. * * Этап 4 редактора моделей: воксельный редактор полностью функционален. * - Babylon-сцена с орбит-камерой и сеткой 8/16/32/64 * - Инструменты: Рисовать / Стереть / Раскрасить / Заливка (Shift+ЛКМ = инверсия draw) * - Палитра 32 цвета + color-picker * - Сохранение в БД через API из Этапа 1 (createUserModel / updateUserModel) * - Thumbnail снимается автоматически с текущего вида camera * * Гладкий редактор (mode='smooth') пока placeholder — будет на Этапе 7. * * Props: * mode — 'voxel' | 'smooth' * userId — id текущего пользователя (для сохранения) * onClose — закрыть и вернуться в редактор сцены * editingModelId — если передан, загружаем существующую модель для редактирования */ // Базовая палитра 32 цветов — natural + bright spectrum, // удобная для быстрого выбора (как в MagicaVoxel). const DEFAULT_PALETTE = [ // Тёплые '#ffffff', '#cccccc', '#888888', '#444444', '#000000', '#7c5430', '#a06a3a', '#553a25', // Зелень '#52b15a', '#5fa84e', '#3f7a3a', '#1f5a1f', '#a8e060', '#6ab04c', // Голубой / морской '#3a8fd6', '#4673b8', '#1e4d8c', '#7ec8ff', '#b4e4ff', // Тёплые акценты '#d4c84a', '#e6d27a', '#f7c95c', '#ff9933', '#cc6633', // Красное '#b84141', '#d44a4a', '#7a1f1f', '#ff5577', // Фиолет/розовый '#9b5de5', '#c084fc', '#ff66cc', // Снег/лёд '#e0f0ff', ]; const TOOLS = [ { id: 'draw', label: 'Рисовать', icon: 'paintbrush', desc: 'Поставить voxel выбранного цвета (Shift+ЛКМ = стереть)' }, { id: 'erase', label: 'Стереть', icon: 'eraser', desc: 'Удалить voxel' }, { id: 'paint', label: 'Раскрасить', icon: 'palette', desc: 'Перекрасить voxel в выбранный цвет' }, { id: 'fill', label: 'Заливка', icon: 'sparkles', desc: '3D Flood-fill: перекрасить всю связанную область' }, ]; const MAX_TITLE_LEN = 100; const ModelEditorScreen = ({ mode, userId, onClose, editingModelId = null }) => { const canvasRef = useRef(null); const sceneRef = useRef(null); const [tool, setTool] = useState('draw'); const [color, setColor] = useState('#52b15a'); // Режим материала: 'color' = solid vertex color, 'texture' = одна из VOXEL_TEXTURES const [matMode, setMatMode] = useState('color'); const [textureId, setTextureId] = useState('grass'); const [gridSize, setGridSize] = useState(32); const [brushSize, setBrushSize] = useState(1); // Рабочая плоскость для рисования "в пустоту": ось (X/Y/Z) и уровень 0..size-1 const [planeAxis, setPlaneAxis] = useState('y'); const [planeLevel, setPlaneLevel] = useState(0); const [voxelCount, setVoxelCount] = useState(0); const [title, setTitle] = useState('Новая модель'); const [description, setDescription] = useState(''); const [isPublic, setIsPublic] = useState(false); const [referenceVisible, setReferenceVisible] = useState(false); // Bumper для триггера ре-рендера канюки undo/redo (стеки внутри сцены). const [, setHistoryBump] = useState(0); const [isDirty, setIsDirty] = useState(false); // Индикатор загрузки существующей модели — пока true, поверх канваса // показываем оверлей со спиннером (иначе кажется что редактор завис). const [isLoadingModel, setIsLoadingModel] = useState(!!editingModelId); const [savedId, setSavedId] = useState(editingModelId || null); const [saveStatus, setSaveStatus] = useState('idle'); // 'idle' | 'dirty' | 'saving' | 'saved' | 'error' const [saveError, setSaveError] = useState(''); // Модалка "Сохранить как новую модель" — открывается на первом сохранении. const [saveModalOpen, setSaveModalOpen] = useState(false); // Этап 6: процесс публикации модели в сообщество (true пока запрос идёт). const [publishBusy, setPublishBusy] = useState(false); // Таймер debounce для автосейва. const autoSaveTimerRef = useRef(null); // === Режим интерактивного создания превью === // Пользователь жмёт "Создать превью" в модалке → модалка скрывается, // канвас затемняется, мышью выделяется КВАДРАТНАЯ область → она // кадрируется в 128×128 PNG и возвращается в модалку как обложка. const [previewMode, setPreviewMode] = useState(false); // Снимок канваса (PNG data URL + размеры рендера) — берётся при входе // в режим, из него потом вырезается выделенный квадрат. const previewSnapshotRef = useRef(null); // { dataUrl, width, height } // Текущее выделение в КООРДИНАТАХ ЭКРАНА (CSS-пиксели относительно // overlay): { x, y, size } — null пока не начали тянуть. const [previewSel, setPreviewSel] = useState(null); // Идёт ли перетаскивание прямо сейчас. const previewDragRef = useRef(null); // { startX, startY } // Ref на DOM-обёртку канваса — для расчёта координат мыши. const canvasWrapRef = useRef(null); // Превью, которое уже создано и ждёт применения в модалке. const pendingPreviewRef = useRef(''); const isVoxel = mode === 'voxel'; // === Инициализация Babylon-сцены (только для voxel) === useEffect(() => { if (!isVoxel) return; if (!canvasRef.current) return; const scene = new VoxelModelScene(canvasRef.current, { gridSize: 32, initialColor: color, onChange: (count) => { setVoxelCount(count); setIsDirty(true); // Триггер ре-рендера чтобы Undo/Redo кнопки обновили disabled setHistoryBump((n) => n + 1); }, }); sceneRef.current = scene; return () => { try { scene.dispose(); } catch (e) {} sceneRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVoxel]); // === Загрузка существующей модели для редактирования === useEffect(() => { if (!isVoxel || !editingModelId) return; let cancelled = false; setIsLoadingModel(true); getUserModel(editingModelId, userId) .then(res => { if (cancelled) return; const m = res.data; if (m.kind !== 'voxel') { setIsLoadingModel(false); return; } setTitle(m.title || 'Новая модель'); setDescription(m.description || ''); setIsPublic(!!m.is_public); setSavedId(m.id); if (sceneRef.current && m.model_data) { // deserialize синхронный и тяжёлый (десятки тысяч вокселей). // Откладываем на следующий кадр через rAF — чтобы React // успел отрисовать оверлей-спиннер ДО блокирующей работы, // иначе пользователь видит "зависший" редактор. requestAnimationFrame(() => { if (cancelled || !sceneRef.current) { return; } try { sceneRef.current.deserialize(m.model_data); setGridSize(sceneRef.current.gridSize); setIsDirty(false); } catch (e) { console.error('[ModelEditor] deserialize failed:', e); setSaveError('Не удалось разобрать данные модели'); } finally { if (!cancelled) setIsLoadingModel(false); } }); } else { setIsLoadingModel(false); } }) .catch(err => { console.error('[ModelEditor] load failed:', err); if (!cancelled) { setSaveError('Не удалось загрузить модель'); setIsLoadingModel(false); } }); return () => { cancelled = true; }; }, [isVoxel, editingModelId, userId]); // === Sync пропсов в сцену === useEffect(() => { sceneRef.current?.setTool(tool); }, [tool]); useEffect(() => { if (!sceneRef.current) return; if (matMode === 'texture') { sceneRef.current.setTexture(textureId); } else { sceneRef.current.setColor(color); } }, [matMode, color, textureId]); useEffect(() => { sceneRef.current?.setBrushSize(brushSize); }, [brushSize]); useEffect(() => { sceneRef.current?.setWorkPlaneAxis(planeAxis); }, [planeAxis]); useEffect(() => { sceneRef.current?.setWorkPlaneLevel(planeLevel); }, [planeLevel]); useEffect(() => { sceneRef.current?.setReferenceVisible(referenceVisible); }, [referenceVisible]); // Ctrl+Z / Ctrl+Y — undo/redo useEffect(() => { const onKey = (e) => { const tag = (e.target?.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea') return; if (!e.ctrlKey && !e.metaKey) return; if (e.code === 'KeyZ' && !e.shiftKey) { e.preventDefault(); handleUndo(); } else if (e.code === 'KeyY' || (e.code === 'KeyZ' && e.shiftKey)) { e.preventDefault(); handleRedo(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleUndo = useCallback(() => { if (sceneRef.current?.undo()) { setIsDirty(true); setHistoryBump((n) => n + 1); } }, []); const handleRedo = useCallback(() => { if (sceneRef.current?.redo()) { setIsDirty(true); setHistoryBump((n) => n + 1); } }, []); // === Действия === const handleResize = (newSize) => { if (!sceneRef.current) return; if (voxelCount > 0) { // eslint-disable-next-line no-alert if (!window.confirm(`Изменение размера очистит все ${voxelCount} вокселей. Продолжить?`)) return; } sceneRef.current.resize(newSize); setGridSize(newSize); setVoxelCount(0); // Подвинем рабочую плоскость в середину новой сетки setPlaneLevel(Math.floor(newSize / 2)); setIsDirty(true); }; const handleClear = () => { if (!sceneRef.current) return; if (voxelCount === 0) return; // eslint-disable-next-line no-alert if (!window.confirm(`Удалить все ${voxelCount} вокселей?`)) return; sceneRef.current.clear(); setIsDirty(true); }; /** * Реальное обращение к API. Принимает override полей (title/description/ * is_public/thumbnail) — модалка передаёт их при первом сохранении. * Без override используются текущие state-значения. */ const performSave = useCallback(async (override = {}) => { if (!sceneRef.current) return false; if (!userId) { setSaveError('Войдите в аккаунт, чтобы сохранить'); setSaveStatus('error'); return false; } const t = (override.title ?? title).trim(); if (!t) { setSaveError('Введите название модели'); setSaveStatus('error'); return false; } if (voxelCount === 0) { setSaveError('Поставьте хотя бы один voxel'); setSaveStatus('error'); return false; } setSaveStatus('saving'); setSaveError(''); try { const model_data = sceneRef.current.serialize(); let thumbnail_b64 = override.thumbnail ?? ''; if (!thumbnail_b64) { try { thumbnail_b64 = await sceneRef.current.captureThumbnail(128, 128); } catch (e) { /* ignore */ } } const payload = { title: t, description: override.description ?? description, kind: 'voxel', model_data, thumbnail_b64, }; if (override.is_public !== undefined) { // Сначала пишем через create/update, потом отдельный publish-вызов } let res; const newPublicFlag = override.is_public ?? isPublic; if (savedId) { res = await updateUserModel(savedId, userId, payload); } else { res = await createUserModel(userId, payload); setSavedId(res.data.id); } // Синхронизируем флаг публичности отдельным запросом, если есть. const newId = res.data.id; if (newPublicFlag && !res.data.is_public) { try { const { publishUserModel } = await import('../api/Kubikon3DService'); await publishUserModel(newId, userId); } catch (e) { /* ignore */ } } else if (!newPublicFlag && res.data.is_public) { try { const { unpublishUserModel } = await import('../api/Kubikon3DService'); await unpublishUserModel(newId, userId); } catch (e) { /* ignore */ } } // Применяем override локально (модалка успешно сохранена) if (override.title !== undefined) setTitle(t); if (override.description !== undefined) setDescription(override.description); if (override.is_public !== undefined) setIsPublic(override.is_public); setSaveStatus('saved'); setIsDirty(false); setTimeout(() => setSaveStatus((s) => s === 'saved' ? 'idle' : s), 2000); return true; } catch (err) { console.error('[ModelEditor] save failed:', err); setSaveError(err.response?.data?.error || 'Ошибка сохранения'); setSaveStatus('error'); return false; } }, [userId, title, description, isPublic, voxelCount, savedId]); /** Клик "Сохранить" в шапке. * Всегда открывает модалку — и для новой модели, и для существующей. * Так пользователь видит превью обложки и может пересоснять её * («Снять снова»), а также правит название/описание/публичность. * Тихое автосохранение (debounce) идёт отдельно, модалку не трогает. */ const handleSaveClick = useCallback(() => { if (!sceneRef.current) return; if (voxelCount === 0) { setSaveError('Поставьте хотя бы один voxel'); setSaveStatus('error'); return; } setSaveModalOpen(true); }, [voxelCount]); /** Callback из модалки — пользователь подтвердил сохранение. */ const handleSaveFromModal = useCallback(async (data) => { const ok = await performSave(data); if (ok) setSaveModalOpen(false); }, [performSave]); /** Этап 6.1: кнопка «Опубликовать» / «Снять с публикации» в шапке. * Публиковать можно только сохранённую модель (нужен savedId). * Публикация делает модель видимой всем в Toolbox → «Сообщество». */ const handleTogglePublish = useCallback(async () => { if (!savedId || !userId || publishBusy) return; const goingPublic = !isPublic; // eslint-disable-next-line no-alert const ok = window.confirm( goingPublic ? 'Опубликовать модель? Она станет видна всем в разделе «Сообщество» — другие смогут ставить её в свои игры.' : 'Снять модель с публикации? Она исчезнет из «Сообщества», но останется у вас.', ); if (!ok) return; setPublishBusy(true); try { if (goingPublic) { await publishUserModel(savedId, userId); } else { await unpublishUserModel(savedId, userId); } setIsPublic(goingPublic); } catch (err) { console.error('[ModelEditor] publish toggle failed:', err); // eslint-disable-next-line no-alert alert('Не удалось изменить публичность модели. Проверьте подключение.'); } finally { setPublishBusy(false); } }, [savedId, userId, isPublic, publishBusy]); /** Захват превью для модалки сохранения (автоснимок при открытии). */ const captureThumbForModal = useCallback(async () => { if (!sceneRef.current) return ''; try { return await sceneRef.current.captureThumbnail(128, 128); } catch (e) { return ''; } }, []); // === Интерактивное создание превью === /** Вход в режим выделения превью: прячем модалку, скрываем вспомогательные * элементы сцены (рамка/пол/рабочая плоскость) и снимаем снимок канваса. */ const enterPreviewMode = useCallback(async () => { if (!sceneRef.current) return; setSaveModalOpen(false); setPreviewSel(null); previewDragRef.current = null; // Скрываем рамку/пол/рабочую плоскость — чтобы пользователь видел на // канвасе ровно то, что попадёт в превью (и снимок был чистым). try { sceneRef.current.hideHelpersForPreview(); } catch (e) {} // Снимок канваса в его текущем виде — из него вырежем квадрат. try { const snap = await sceneRef.current.captureCanvasSnapshot(); previewSnapshotRef.current = snap; } catch (e) { previewSnapshotRef.current = null; } setPreviewMode(true); }, []); /** Отмена режима выделения — возвращаемся в модалку без изменений. */ const cancelPreviewMode = useCallback(() => { setPreviewMode(false); setPreviewSel(null); previewDragRef.current = null; previewSnapshotRef.current = null; // Возвращаем видимость рамки/пола/рабочей плоскости. try { sceneRef.current?.restoreHelpersAfterPreview(); } catch (e) {} setSaveModalOpen(true); }, []); /** Подтвердить выделение: вырезать квадрат → вернуть в модалку как превью. */ const confirmPreviewSelection = useCallback(async () => { const snap = previewSnapshotRef.current; const sel = previewSel; const wrap = canvasWrapRef.current; if (!snap || !snap.dataUrl || !sel || !wrap) { cancelPreviewMode(); return; } // Координаты выделения — в CSS-пикселях overlay. Снимок — в // рендер-пикселях (snap.width/height). Переводим через коэффициент. const rect = wrap.getBoundingClientRect(); const scaleX = snap.width / rect.width; const scaleY = snap.height / rect.height; // Выделение квадратное в CSS — но из-за разных scaleX/scaleY в // снимке оно может стать прямоугольным. Берём минимальную сторону, // чтобы гарантированно вырезать квадрат внутри выделения. const sx = Math.round(sel.x * scaleX); const sy = Math.round(sel.y * scaleY); const sSize = Math.round(sel.size * Math.min(scaleX, scaleY)); try { const VoxelModelSceneClass = sceneRef.current?.constructor; const cropped = VoxelModelSceneClass ? await VoxelModelSceneClass.cropSquareThumbnail( snap.dataUrl, sx, sy, sSize, 128) : ''; pendingPreviewRef.current = cropped || ''; } catch (e) { pendingPreviewRef.current = ''; } setPreviewMode(false); setPreviewSel(null); previewDragRef.current = null; previewSnapshotRef.current = null; // Возвращаем видимость рамки/пола/рабочей плоскости. try { sceneRef.current?.restoreHelpersAfterPreview(); } catch (e) {} setSaveModalOpen(true); }, [previewSel, cancelPreviewMode]); // --- Мышь в режиме выделения --- const onPreviewPointerDown = useCallback((e) => { if (!previewMode) return; const wrap = canvasWrapRef.current; if (!wrap) return; const rect = wrap.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; previewDragRef.current = { startX: x, startY: y }; setPreviewSel({ x, y, size: 0 }); try { wrap.setPointerCapture(e.pointerId); } catch (err) {} }, [previewMode]); const onPreviewPointerMove = useCallback((e) => { const drag = previewDragRef.current; if (!previewMode || !drag) return; const wrap = canvasWrapRef.current; if (!wrap) return; const rect = wrap.getBoundingClientRect(); const curX = e.clientX - rect.left; const curY = e.clientY - rect.top; // Квадрат: сторона = max(|dx|,|dy|), направление — куда тянут. const dx = curX - drag.startX; const dy = curY - drag.startY; let size = Math.max(Math.abs(dx), Math.abs(dy)); let x = dx < 0 ? drag.startX - size : drag.startX; let y = dy < 0 ? drag.startY - size : drag.startY; // Клампим выделение в границы overlay. if (x < 0) { size += x; x = 0; } if (y < 0) { size += y; y = 0; } if (x + size > rect.width) size = rect.width - x; if (y + size > rect.height) size = rect.height - y; size = Math.max(0, size); setPreviewSel({ x, y, size }); }, [previewMode]); const onPreviewPointerUp = useCallback((e) => { if (!previewMode) return; previewDragRef.current = null; const wrap = canvasWrapRef.current; try { wrap?.releasePointerCapture(e.pointerId); } catch (err) {} }, [previewMode]); // === Esc — закрыть с подтверждением если dirty === // Объявлено ПОСЛЕ preview-обработчиков: эффект ссылается на // cancelPreviewMode (const useCallback не поднимается hoisting'ом). useEffect(() => { const onKey = (e) => { if (e.key !== 'Escape') return; // В режиме выделения превью — Esc отменяет выделение, не редактор. if (previewMode) { cancelPreviewMode(); return; } // Не закрываем редактор если открыта модалка — её закроет свой // обработчик через onClose. if (saveModalOpen) return; if (isDirty) { // eslint-disable-next-line no-alert if (!window.confirm('Есть несохранённые изменения. Выйти без сохранения?')) return; } onClose?.(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, isDirty, saveModalOpen, previewMode, cancelPreviewMode]); // === Автосейв === // Срабатывает только если модель уже была сохранена (есть savedId). // Новые модели сохраняются только через модалку (требуется название). // Debounce — 2.5с после последнего изменения. useEffect(() => { if (!isDirty || !savedId || !userId) return; if (saveModalOpen) return; // не дёргаем во время открытой модалки if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); setSaveStatus((s) => s === 'idle' || s === 'saved' ? 'dirty' : s); autoSaveTimerRef.current = setTimeout(() => { autoSaveTimerRef.current = null; performSave(); }, AUTOSAVE_DEBOUNCE_MS); return () => { if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } }; }, [isDirty, savedId, userId, saveModalOpen, performSave]); // === Smooth (mode !== 'voxel') — пока placeholder === if (!isVoxel) { return (