Большой консолидирующий коммит после поднятия studio.rublox.pro (28 мая 2026). Содержит изменения которые делались в процессе подготовки прод-окружения: Фиксы импортов после выноса из minecraftia: - Массовая замена путей ../../components → ../components (40+ файлов в src/community/, src/admin-preview/) - Замена ../KubikonEditor/ → ../editor/, ../KubikonStudio/ → ../community/, ../AdminPreview/ → ../admin-preview/ - API.js скопирован из минки целиком (было 8 экспортов, стало 312) - Добавлены PLAYER_URL, MyButton_1, недостающие компоненты - Заменены require() на статические ES-imports в BabylonScene, PrimitiveManager, GameRuntime (Vite не поддерживает CJS require) Структура ассетов: - public/kubikon-templates/ → public/assets/kubikon-templates/ - public/kubikon-learn/ → public/assets/kubikon-learn/ - (код искал в /assets/, файлы лежали без /assets/) Навигация роутов внутри студии: - /kubikon-studio/docs → /docs (90+ навигационных вызовов sed-replaced) - /kubikon-editor/X → /edit/X, /kubikon/play/X → /play/X, /kubikon/gd/X → /gd/X UI: - Новый компонент StudioHeader (61px, как в минке) + копия favicon - WithHeader wrapper в App.jsx для всех страниц кроме fullscreen-редактора/плеера - SSO ticket-flow в AuthContext (auto-redeem #ticket= при загрузке) - Тёмная тема карточек игр в ВИКИ (фон #1c2231 вместо #fff, картинка впритык) Документация: - docs/ONBOARDING.md — путь нового контрибьютора от нуля до PR - docs/TUTORIAL_ADD_SCRIPT_API.md — как добавить game.* API - API_USAGE.md — список эндпоинтов backend - README в подпапках engine/, engine/terrain/, engine/voxel/, engine/robloxterrain/, engine/types/ .gitignore: - public/wiki/ исключён (73МБ PNG, будут на CDN отдельной задачей) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
972 lines
49 KiB
JavaScript
972 lines
49 KiB
JavaScript
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 (
|
||
<div className={cl.screen}>
|
||
<header className={cl.header}>
|
||
<button type="button" className={cl.backBtn} onClick={onClose}>
|
||
<Icon name="arrow-left" size={14} /> Назад
|
||
</button>
|
||
<div className={cl.titleBlock}>
|
||
<div className={cl.title}><Icon emoji="⚪" size={16} /> Редактор гладкой модели</div>
|
||
<div className={cl.subtitle}>Mesh из примитивов — как Roblox Studio</div>
|
||
</div>
|
||
</header>
|
||
<main className={cl.body}>
|
||
<div className={cl.placeholder}>
|
||
<div className={cl.placeholderIcon}><Icon emoji="⚪" size={48} /></div>
|
||
<div className={cl.placeholderTitle}>Гладкий редактор появится позже</div>
|
||
<div className={cl.placeholderText}>
|
||
Сейчас работает только воксельный редактор. Гладкая модель
|
||
(mesh из примитивов) — Этап 7 плана.
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// === Voxel-редактор ===
|
||
return (
|
||
<div className={cl.screen}>
|
||
<header className={cl.header}>
|
||
<button
|
||
type="button"
|
||
className={cl.backBtn}
|
||
onClick={() => {
|
||
if (isDirty) {
|
||
// eslint-disable-next-line no-alert
|
||
if (!window.confirm('Есть несохранённые изменения. Выйти без сохранения?')) return;
|
||
}
|
||
onClose?.();
|
||
}}
|
||
title="Назад в редактор сцены (Esc)"
|
||
>
|
||
<Icon name="arrow-left" size={14} /> Назад
|
||
</button>
|
||
|
||
<div className={cl.titleBlock}>
|
||
<input
|
||
type="text"
|
||
className={cl.titleInput}
|
||
value={title}
|
||
onChange={(e) => { setTitle(e.target.value.slice(0, MAX_TITLE_LEN)); setIsDirty(true); }}
|
||
placeholder="Название модели"
|
||
maxLength={MAX_TITLE_LEN}
|
||
/>
|
||
<div className={cl.subtitle}>
|
||
<Icon emoji="🟦" size={13} /> Воксельная · {voxelCount} вокс. · сетка {gridSize}³ · кисть {brushSize}³
|
||
{saveStatus === 'saving' && <span className={cl.savedNote}>· сохраняем...</span>}
|
||
{saveStatus === 'dirty' && <span className={cl.dirtyDot} title="Автосохранение через несколько секунд">•</span>}
|
||
{saveStatus === 'saved' && <span className={cl.savedNote}><Icon name="check" size={13} /> сохранено</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.actions}>
|
||
<button
|
||
type="button"
|
||
className={cl.iconBtn}
|
||
onClick={handleUndo}
|
||
disabled={!sceneRef.current?.canUndo()}
|
||
title="Отменить (Ctrl+Z)"
|
||
>
|
||
<Icon name="arrow-left" size={14} /> Назад
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cl.iconBtn}
|
||
onClick={handleRedo}
|
||
disabled={!sceneRef.current?.canRedo()}
|
||
title="Повторить (Ctrl+Y)"
|
||
>
|
||
<Icon name="arrow-right" size={14} /> Вперёд
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`${cl.iconBtn} ${referenceVisible ? cl.iconBtnActive : ''}`}
|
||
onClick={() => setReferenceVisible(v => !v)}
|
||
title="Показать референс роста человека (2м = 8 вокселей)"
|
||
>
|
||
<Icon emoji="🧍" size={14} /> Масштаб
|
||
</button>
|
||
{saveStatus === 'error' && <span className={cl.errorNote}><Icon name="warning" size={13} /> {saveError}</span>}
|
||
<button
|
||
type="button"
|
||
className={cl.saveBtn}
|
||
onClick={handleSaveClick}
|
||
disabled={saveStatus === 'saving' || voxelCount === 0}
|
||
>
|
||
<Icon name="save" size={13} />
|
||
{saveStatus === 'saving' ? ' Сохранение...' : ' Сохранить'}
|
||
</button>
|
||
{/* Этап 6.1: публикация модели в сообщество.
|
||
Активна только для сохранённой модели (нужен savedId). */}
|
||
<button
|
||
type="button"
|
||
className={`${cl.iconBtn} ${isPublic ? cl.iconBtnActive : ''}`}
|
||
onClick={handleTogglePublish}
|
||
disabled={!savedId || publishBusy}
|
||
title={savedId
|
||
? (isPublic
|
||
? 'Снять модель с публикации'
|
||
: 'Опубликовать модель в «Сообщество»')
|
||
: 'Сначала сохраните модель'}
|
||
>
|
||
<Icon name={isPublic ? 'globe' : 'upload'} size={13} />
|
||
{publishBusy
|
||
? ' …'
|
||
: (isPublic ? ' Опубликовано' : ' Опубликовать')}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className={cl.workspace}>
|
||
{/* === Левая панель: инструменты === */}
|
||
<aside className={cl.sidebar}>
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}>Инструменты</div>
|
||
<div className={cl.toolsGrid}>
|
||
{TOOLS.map(t => (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
className={`${cl.toolBtn} ${tool === t.id ? cl.toolBtnActive : ''}`}
|
||
onClick={() => setTool(t.id)}
|
||
title={t.desc}
|
||
>
|
||
<Icon name={t.icon} size={16} />
|
||
<span>{t.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.matModeTabs}>
|
||
<button
|
||
type="button"
|
||
className={`${cl.matModeTab} ${matMode === 'color' ? cl.matModeTabActive : ''}`}
|
||
onClick={() => setMatMode('color')}
|
||
>
|
||
<Icon emoji="🎨" size={14} /> Цвет
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`${cl.matModeTab} ${matMode === 'texture' ? cl.matModeTabActive : ''}`}
|
||
onClick={() => setMatMode('texture')}
|
||
>
|
||
<Icon emoji="🧱" size={14} /> Текстура
|
||
</button>
|
||
</div>
|
||
|
||
{matMode === 'color' && (
|
||
<>
|
||
<div className={cl.paletteGrid}>
|
||
{DEFAULT_PALETTE.map(c => (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
className={`${cl.colorSwatch} ${color === c ? cl.colorSwatchActive : ''}`}
|
||
style={{ background: c }}
|
||
onClick={() => setColor(c)}
|
||
title={c}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className={cl.colorPickerRow}>
|
||
<input
|
||
type="color"
|
||
value={color}
|
||
onChange={(e) => setColor(e.target.value)}
|
||
className={cl.colorPicker}
|
||
title="Свой цвет"
|
||
/>
|
||
<span className={cl.colorHex}>{color}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{matMode === 'texture' && (
|
||
<div className={cl.textureGrid}>
|
||
{VOXEL_TEXTURES.map(t => (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
className={`${cl.textureSwatch} ${textureId === t.id ? cl.textureSwatchActive : ''}`}
|
||
onClick={() => setTextureId(t.id)}
|
||
title={t.label}
|
||
>
|
||
<img src={t.file} alt={t.label} />
|
||
<span>{t.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}>Размер кисти</div>
|
||
<div className={cl.sizeRow}>
|
||
{[1, 2, 3, 4, 5].map(n => (
|
||
<button
|
||
key={n}
|
||
type="button"
|
||
className={`${cl.sizeBtn} ${brushSize === n ? cl.sizeBtnActive : ''}`}
|
||
onClick={() => setBrushSize(n)}
|
||
title={`Кисть ${n}×${n}×${n} вокселей за клик`}
|
||
>
|
||
{n}³
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className={cl.hintText}>
|
||
Большая кисть = «толстый блок» — используй разные размеры
|
||
для разных частей модели (как стволы деревьев).
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}>
|
||
Рабочая плоскость · <span className={cl.muted}>{planeAxis.toUpperCase()}={planeLevel}</span>
|
||
</div>
|
||
<div className={cl.axisRow}>
|
||
{['x', 'y', 'z'].map(a => (
|
||
<button
|
||
key={a}
|
||
type="button"
|
||
className={`${cl.axisBtn} ${planeAxis === a ? cl.axisBtnActive : ''}`}
|
||
onClick={() => {
|
||
setPlaneAxis(a);
|
||
setPlaneLevel(Math.floor(gridSize / 2));
|
||
}}
|
||
title={`Плоскость перпендикулярна оси ${a.toUpperCase()}`}
|
||
>
|
||
{a.toUpperCase()}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max={gridSize - 1}
|
||
step="1"
|
||
value={planeLevel}
|
||
onChange={(e) => setPlaneLevel(Number(e.target.value))}
|
||
className={cl.slider}
|
||
/>
|
||
<div className={cl.hintText}>
|
||
Клик по синей плоскости ставит voxel на её уровне.
|
||
Меняй ось чтобы рисовать сбоку (X/Z) или сверху (Y),
|
||
ползунок — чтобы выбрать конкретный срез.
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<div className={cl.sectionTitle}>Размер сетки</div>
|
||
<div className={cl.sizeRow}>
|
||
{ALLOWED_GRID_SIZES.map(s => (
|
||
<button
|
||
key={s}
|
||
type="button"
|
||
className={`${cl.sizeBtn} ${gridSize === s ? cl.sizeBtnActive : ''}`}
|
||
onClick={() => handleResize(s)}
|
||
>
|
||
{s}³
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cl.section}>
|
||
<button
|
||
type="button"
|
||
className={cl.dangerBtn}
|
||
onClick={handleClear}
|
||
disabled={voxelCount === 0}
|
||
>
|
||
<Icon name="trash" size={13} /> Очистить
|
||
</button>
|
||
</div>
|
||
|
||
<div className={cl.hintBlock}>
|
||
<div><b>ЛКМ</b> — рисовать</div>
|
||
<div><b>Shift+ЛКМ</b> — стереть</div>
|
||
<div><b>ПКМ</b> — повернуть камеру</div>
|
||
<div><b>ПКМ + WASD</b> — летать</div>
|
||
<div><b>ПКМ + Q/E</b> — вниз/вверх</div>
|
||
<div><b>ПКМ + Shift</b> — быстрее</div>
|
||
<div><b>Колесо</b> — приблизить</div>
|
||
<div><b>F</b> — центрировать</div>
|
||
<div><b>Ctrl+Z/Y</b> — отмена/повтор</div>
|
||
<div><b>Esc</b> — выйти</div>
|
||
</div>
|
||
</aside>
|
||
|
||
{/* === Babylon canvas === */}
|
||
<div
|
||
className={cl.canvasWrap}
|
||
ref={canvasWrapRef}
|
||
onPointerDown={previewMode ? onPreviewPointerDown : undefined}
|
||
onPointerMove={previewMode ? onPreviewPointerMove : undefined}
|
||
onPointerUp={previewMode ? onPreviewPointerUp : undefined}
|
||
>
|
||
<canvas ref={canvasRef} className={cl.canvas} />
|
||
{/* Оверлей загрузки модели — пока deserialize не закончился,
|
||
чтобы редактор не выглядел "зависшим". */}
|
||
{isLoadingModel && (
|
||
<div className={cl.loadingOverlay}>
|
||
<div className={cl.loadingSpinner} />
|
||
<div className={cl.loadingText}>Загрузка модели…</div>
|
||
</div>
|
||
)}
|
||
{/* Оверлей выделения превью: затемнение + квадратное
|
||
выделение мышью. Внутри выделения затемнение убрано
|
||
(модель видна чисто) — через box-shadow на рамке. */}
|
||
{previewMode && (
|
||
<div className={cl.previewOverlay}>
|
||
{/* Затемняющий слой — показан только пока ничего
|
||
не выделено. Когда есть выделение, используем
|
||
рамку с огромной тенью (вырез "окошка"). */}
|
||
{(!previewSel || previewSel.size < 4) && (
|
||
<div className={cl.previewDim} />
|
||
)}
|
||
{previewSel && previewSel.size >= 4 && (
|
||
<div
|
||
className={cl.previewSelBox}
|
||
style={{
|
||
left: previewSel.x,
|
||
top: previewSel.y,
|
||
width: previewSel.size,
|
||
height: previewSel.size,
|
||
}}
|
||
/>
|
||
)}
|
||
<div className={cl.previewHint}>
|
||
{previewSel && previewSel.size >= 4
|
||
? 'Отпусти мышь — затем нажми «Создать»'
|
||
: 'Выдели квадратную область для превью'}
|
||
</div>
|
||
<div
|
||
className={cl.previewActions}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={cl.previewCancelBtn}
|
||
onClick={cancelPreviewMode}
|
||
>
|
||
Отмена
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cl.previewCreateBtn}
|
||
onClick={confirmPreviewSelection}
|
||
disabled={!previewSel || previewSel.size < 8}
|
||
>
|
||
Создать
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Модалка "Сохранить как новую модель" — открывается на первое
|
||
сохранение и при каждом клике "Сохранить".
|
||
initial.thumbnail — превью, созданное через интерактивное
|
||
выделение (enterPreviewMode → confirmPreviewSelection).
|
||
Если оно есть, модалка покажет его вместо автоснимка. */}
|
||
<ModelSaveModal
|
||
open={saveModalOpen}
|
||
initial={{
|
||
title,
|
||
description,
|
||
is_public: isPublic,
|
||
thumbnail: pendingPreviewRef.current || '',
|
||
}}
|
||
onClose={() => {
|
||
pendingPreviewRef.current = '';
|
||
setSaveModalOpen(false);
|
||
}}
|
||
onSave={(data) => {
|
||
pendingPreviewRef.current = '';
|
||
return handleSaveFromModal(data);
|
||
}}
|
||
onCaptureThumbnail={captureThumbForModal}
|
||
onCreatePreview={enterPreviewMode}
|
||
mode={savedId ? 'edit' : 'create'}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ModelEditorScreen;
|