studio/src/editor/ModelEditorScreen.jsx
МИН 61fba4e174
Some checks failed
CI / Lint + Format (push) Failing after 32s
CI / Build (push) Failing after 37s
CI / Secret scan (push) Failing after 37s
CI / PR size check (push) Has been skipped
fix: починка билда + studio.rublox.pro инфра
Большой консолидирующий коммит после поднятия 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>
2026-05-28 05:01:13 +03:00

972 lines
49 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.

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;