studio/src/editor/TerrainPanel.jsx
МИН 31adbf151b Initial public release: Студия Рублокса v1.0
Open-source веб-студия для создания игр Рублокса, двойная лицензия
AGPL-3.0 + Коммерческая.

Главное:
- Vite 5 + React 18 + Babylon 7.54.3 + Monaco Editor + Colyseus 0.16
- Самодостаточный движок ~28к строк (66 файлов): BlockManager,
  TerrainVoxelBuilder, ModelManager, DecoManager, PlayerController,
  ScriptSandboxWorker, MultiplayerSync, 30+ GD-гейммодов
- Главный редактор KubikonEditor (~37к строк) + панели, ScriptEditor (Monaco)
- Витрина игр (KubikonFeed, KubikonStudio, KubikonDocs, KubikonLearn)
- Geometry Dash sub-app (GdMenu, GdShop, GdRules, GdCoverArt)
- 10 admin-preview каталогов для дизайнеров (скины, музыка, порталы и т.д.)
- Конфигурируемый бэкенд через VITE_API_BASE — работает со staging
  (dev-api.rublox.pro) без настройки
- Standalone-режим (VITE_STANDALONE=true) — открыть пустой редактор без бэка
- Полная документация (на русском): README, ARCHITECTURE, CONTRIBUTING,
  SECURITY, CHANGELOG
- ESLint + Prettier + EditorConfig
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Перед публикацией:
- Все импорты из minecraftia заменены на локальные
- Все хардкоды URL (minecraftia-school.ru) и внутренних IP убраны → env
- Admin-эндпоинты Kubikon3DService вырезаны (остаются в приватном репо)
- AdminKubikonModeration не публикуется (модерация — в team.rublox.pro)
- 93 МБ ассетов public/kubikon-assets вынесены в .gitignore
  (раздаются через release artifact)
2026-05-27 23:41:10 +03:00

521 lines
29 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.

/**
* TerrainPanel — левая боковая панель редактора ландшафта.
*
* Дизайн повторяет Roblox Studio → Terrain Editor: сетка 4×N кнопок-инструментов
* (иконка + подпись), снизу — секция «Параметры инструмента» (пока пустая,
* заглушка под brush size / strength / material).
*
* Полный список инструментов (как в RS, перевод на русский):
* 1. Выделить (Select) — выбор региона
* 2. Преобразовать (Transform) — move/scale/rotate выбранного региона
* 3. Заполнить (Fill) — залить регион материалом
* 4. Уровень моря (Sea Level) — поставить плоскость воды на высоту Y
* 5. Рисовать (Draw) — кистью добавлять voxels
* 6. Скульпт (Sculpt) — поднимать/опускать поверхность
* 7. Сгладить (Smooth) — сглаживать неровности
* 8. Раскрасить (Paint) — менять материал voxel'ов без изменения формы
* 9. Выровнять (Flatten) — выровнять по плоскости
*
* Все обработчики — заглушки (console.log). Подключение к движку — следующий этап.
*
* Иконки — inline SVG (никаких новых зависимостей, цвет наследуется через
* currentColor). Стиль монохромный, под тёмную тему Кубикона.
*/
import React, { useState, useEffect } from 'react';
import Icon from './Icon';
import cl from './TerrainPanel.module.css';
// ============================================================================
// Inline-SVG иконки. Все 24×24, stroke 1.6, currentColor. Стилизованы под
// Roblox Studio Terrain Editor (минималистичные пиктограммы).
// ============================================================================
const Ic = {
Select: () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 3l4 14 2.5-5.5L17 9z" />
</svg>
),
Transform: () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 7l4-4 4 4M19 17l-4 4-4-4M5 7v10M19 7v10M5 17l4 4M15 3h4v4" />
</svg>
),
Fill: () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 12l8-8 8 8-8 8z" />
<path d="M4 12c0 2 2 4 4 4M19 17a2 2 0 1 1-4 0c0-1.5 2-4 2-4s2 2.5 2 4z" />
</svg>
),
SeaLevel: () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4l18 4-18 4z" />
<path d="M3 16c3 0 3-1 6-1s3 1 6 1 3-1 6-1" />
<path d="M3 20c3 0 3-1 6-1s3 1 6 1 3-1 6-1" />
</svg>
),
Draw: () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 20l5-1 11-11-4-4L5 15z" />
<path d="M15 5l4 4" />
<path d="M17 3l1.5 1.5" />
</svg>
),
Sculpt: () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 19l5-9 4 5 3-4 6 8z" />
<circle cx="17" cy="6" r="2.2" />
</svg>
),
Smooth: () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 16c3 0 3-6 6-6s3 6 6 6 3-6 6-6" />
<path d="M14 4l3 3-1.5 1.5" />
</svg>
),
Paint: () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 20l4-1 11-11-3-3L4 16z" />
<path d="M15 5l3 3" />
<circle cx="19" cy="5" r="1.5" />
</svg>
),
Flatten: () => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 15h18" strokeDasharray="2 2" />
<path d="M5 19l3-5 4 3 3-6 4 4" />
</svg>
),
};
// ============================================================================
// Описание всех инструментов — единое место правды, чтобы добавлять/убирать
// инструменты было удобно.
// ============================================================================
// Полный набор инструментов для VOXEL-режима.
const TOOLS_VOXEL = [
{ id: 'draw', label: 'Рисовать', icon: Ic.Draw, desc: 'Кисть: добавлять voxel\'ы материала' },
{ id: 'sculpt', label: 'Скульпт', icon: Ic.Sculpt, desc: 'Поднимать или опускать поверхность кистью (Shift — опускать)' },
{ id: 'smooth', label: 'Сгладить', icon: Ic.Smooth, desc: 'Сгладить неровности поверхности (использует существующие материалы)' },
{ id: 'paint', label: 'Раскрасить', icon: Ic.Paint, desc: 'Изменить материал voxel\'ов без изменения формы' },
{ id: 'flatten', label: 'Выровнять', icon: Ic.Flatten, desc: 'Выровнять воxel\'ы по горизонтальной плоскости' },
{ id: 'erase', label: 'Стереть', icon: Ic.Draw, desc: 'Удалить voxel\'ы в зоне кисти' },
// === Plant-кисти: расстановка декораций ===
{ id: 'plantGrass', label: 'Трава', icon: Ic.Draw, desc: 'Посадить пучки травы (Shift — удалить декорации)' },
{ id: 'plantFlower', label: 'Цветы', icon: Ic.Paint, desc: 'Посадить цветы (Shift — удалить декорации)' },
{ id: 'plantMushroom', label: 'Грибы', icon: Ic.Smooth, desc: 'Посадить грибы (Shift — удалить)' },
{ id: 'plantTree', label: 'Деревья', icon: Ic.Sculpt, desc: 'Посадить деревья (Shift — удалить)' },
];
// Инструменты для SMOOTH-режима (как в Roblox Studio Terrain Editor).
const TOOLS_SMOOTH = [
{ id: 'sculpt', label: 'Скульпт', icon: Ic.Sculpt, desc: 'Поднимать поверхность (Shift — опускать)' },
{ id: 'smooth', label: 'Сгладить', icon: Ic.Smooth, desc: 'Сгладить неровности' },
{ id: 'paint', label: 'Раскрасить', icon: Ic.Paint, desc: 'Перекрасить поверхность' },
{ id: 'flatten', label: 'Выровнять', icon: Ic.Flatten, desc: 'Выровнять по плоскости первой точки клика' },
{ id: 'fill', label: 'Заполнить', icon: Ic.Fill, desc: 'Залить сферу density=255' },
{ id: 'erase', label: 'Стереть', icon: Ic.Draw, desc: 'Удалить материал в сфере' },
// === Plant-кисти: расстановка декораций ===
{ id: 'plantGrass', label: 'Трава', icon: Ic.Draw, desc: 'Посадить пучки травы (Shift — удалить декорации)' },
{ id: 'plantFlower', label: 'Цветы', icon: Ic.Paint, desc: 'Посадить цветы (Shift — удалить декорации)' },
{ id: 'plantMushroom', label: 'Грибы', icon: Ic.Smooth, desc: 'Посадить грибы (Shift — удалить)' },
{ id: 'plantTree', label: 'Деревья', icon: Ic.Sculpt, desc: 'Посадить деревья (Shift — удалить)' },
// === Поштучный выбор/удаление декораций ===
{ id: 'pickDeco', label: 'Выбрать деко', icon: Ic.Draw, desc: 'Кликнуть по дереву/кусту/цветку — выделить, Del — удалить выбранное' },
];
// Список материалов террейна. У каждого:
// id — соответствует ключу в TerrainManager.TERRAIN_MATERIALS
// label — отображаемое имя
// color — fallback-цвет для случая если PNG-текстура не загрузилась
// preview — путь к текстуре для иконки в палитре (32×32 px Kenney pack).
// Для grass — берём grass_top.png, для остальных — основную.
const TEX = '/kubikon-assets/textures';
// Материалы террейна. Деко-материалы (листва/ствол/цветы/гриб/трава/мох)
// убраны — они ставятся отдельными plant-инструментами (Трава/Цветы/Грибы/Деревья).
const TERRAIN_MATERIALS = [
{ id: 'grass', label: 'Трава', color: '#52b15a', preview: `${TEX}/grass_top.png` },
{ id: 'rock', label: 'Камень', color: '#7e7e7e', preview: `${TEX}/greystone.png` },
{ id: 'sand', label: 'Песок', color: '#e6d27a', preview: `${TEX}/sand.png` },
{ id: 'snow', label: 'Снег', color: '#f5f7fb', preview: `${TEX}/snow.png` },
{ id: 'dirt', label: 'Земля', color: '#7c5430', preview: `${TEX}/dirt.png` },
{ id: 'water', label: 'Вода', color: '#3a8fd6', preview: `${TEX}/water.png` },
{ id: 'asphalt', label: 'Асфальт', color: '#3b3b3b', preview: `${TEX}/stone.png` },
{ id: 'concrete', label: 'Бетон', color: '#b8b8b8', preview: `${TEX}/greystone.png` },
{ id: 'wood', label: 'Дерево', color: '#a06a3a', preview: `${TEX}/wood.png` },
{ id: 'glacier', label: 'Ледник', color: '#c8e6f5', preview: `${TEX}/ice.png` },
{ id: 'salt', label: 'Соль', color: '#ecedef', preview: `${TEX}/snow.png` },
{ id: 'mud', label: 'Грязь', color: '#553a25', preview: `${TEX}/gravel_dirt.png` },
];
export default function TerrainPanel({ onBrushChange, onAction }) {
// terrainMode: null = экран выбора режима, 'voxel' | 'smooth' = редактор.
const [terrainMode, setTerrainMode] = useState(null);
const [tool, setTool] = useState('sculpt');
const [material, setMaterial] = useState('grass');
const [brushSize, setBrushSize] = useState(4);
const [strength, setStrength] = useState(50);
const [shape, setShape] = useState('sphere');
// Каждый раз когда любое поле кисти меняется — пушим в движок.
// terrainMode тоже передаём — движок переключает логику.
useEffect(() => {
if (!onBrushChange) return;
// Не пушим пока пользователь не выбрал режим — иначе скоулптится
// мимо ожидаемого таргета.
if (terrainMode === null) return;
onBrushChange({ tool, material, brushSize, strength, shape, terrainMode });
}, [onBrushChange, tool, material, brushSize, strength, shape, terrainMode]);
// При переключении в smooth-режим — если текущий material не поддерживается,
// переключаем на grass.
useEffect(() => {
if (terrainMode === 'smooth') {
const supported = ['grass', 'rock', 'sand', 'snow', 'dirt', 'water', 'wood', 'glacier'];
if (!supported.includes(material)) {
setMaterial('grass');
}
}
}, [terrainMode, material]);
// Hotkeys: только пока панель открыта.
// 1..9 0 (=10) — выбор материала из палитры (первые 10)
// Q/W/E/R/T/Y/U/I/O — выбор инструмента в порядке TOOLS
// Ctrl+Z / Ctrl+Y — undo / redo
// [ / ] — уменьшить / увеличить размер кисти
useEffect(() => {
const onKey = (e) => {
// Игнорируем когда печатают в input/textarea
const tag = (e.target?.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
// Ctrl+Z / Ctrl+Y
if ((e.ctrlKey || e.metaKey) && e.code === 'KeyZ' && !e.shiftKey) {
e.preventDefault();
onAction?.('undo');
return;
}
if ((e.ctrlKey || e.metaKey) && (e.code === 'KeyY' || (e.code === 'KeyZ' && e.shiftKey))) {
e.preventDefault();
onAction?.('redo');
return;
}
// Escape снимает выделение региона
if (e.code === 'Escape') {
onAction?.('clear-region');
return;
}
// [ ] — размер кисти
if (e.code === 'BracketLeft') {
e.preventDefault();
setBrushSize((s) => Math.max(1, s - 1));
return;
}
if (e.code === 'BracketRight') {
e.preventDefault();
setBrushSize((s) => Math.min(32, s + 1));
return;
}
// Цифры — выбор материала (1..9, 0 → 10-й)
if (e.code && e.code.startsWith('Digit')) {
const n = parseInt(e.code.slice(5), 10);
const idx = n === 0 ? 9 : n - 1;
if (idx >= 0 && idx < TERRAIN_MATERIALS.length) {
e.preventDefault();
setMaterial(TERRAIN_MATERIALS[idx].id);
}
return;
}
// T/Y/U/I/O/P — основные инструменты (draw/sculpt/smooth/paint/flatten/erase)
// F/C/M/V — plant-кисти (Flora/Cvety/Mushrooms/Vegetation-trees)
const toolKeyMap = {
KeyT: 'draw', KeyY: 'sculpt', KeyU: 'smooth', KeyI: 'paint',
KeyO: 'flatten', KeyP: 'erase',
KeyF: 'plantGrass', KeyC: 'plantFlower', KeyM: 'plantMushroom', KeyV: 'plantTree',
};
if (toolKeyMap[e.code] && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
setTool(toolKeyMap[e.code]);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onAction]);
const handleTool = (id) => setTool(id);
const handleMaterial = (id) => setMaterial(id);
const handleShape = (id) => setShape(id);
// === Экран выбора режима ===
if (terrainMode === null) {
const modeCard = {
display: 'flex', alignItems: 'flex-start', gap: 12,
padding: '14px 16px', background: '#2e2e2e',
border: '1px solid #3a3a3a', borderRadius: 10,
cursor: 'pointer', color: '#e8e8ea', textAlign: 'left',
fontFamily: 'inherit', boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
width: '100%',
};
const modeTitle = { fontSize: 14, fontWeight: 800, color: '#e8e8ea', marginBottom: 6 };
const modeDesc = { fontSize: 12, lineHeight: 1.4, color: '#9a9a9e' };
return (
<div className={cl.panel}>
<div className={cl.header}>
<div className={cl.headerTitle}>Редактор ландшафта</div>
<div className={cl.headerHint}>Выберите тип редактирования</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 10 }}>
<button type="button" onClick={() => setTerrainMode('voxel')} style={modeCard}>
<div style={{ display: 'flex' }}><Icon emoji="🟩" size={32} /></div>
<div style={{ flex: 1 }}>
<div style={modeTitle}>Воксели</div>
<div style={modeDesc}>
Кубический ландшафт с чёткими блоками 0.25м.
Все инструменты Roblox Studio.
</div>
</div>
</button>
<button type="button" onClick={() => setTerrainMode('smooth')} style={modeCard}>
<div style={{ display: 'flex' }}><Icon emoji="🌄" size={32} /></div>
<div style={{ flex: 1 }}>
<div style={modeTitle}>Гладкий ландшафт</div>
<div style={modeDesc}>
Плавные холмы без ступенек, как в Roblox.
Sculpt/Smooth/Paint работают на DensityGrid 4м.
</div>
</div>
</button>
</div>
</div>
);
}
// Активный список инструментов зависит от режима.
const activeTools = terrainMode === 'smooth' ? TOOLS_SMOOTH : TOOLS_VOXEL;
// Палитра smooth-режима: 8 материалов через color (vec4) + tangent (vec4).
// color: grass / rock / sand / snow
// tangent: dirt / water / wood / glacier
// Остальные voxel-материалы маппятся на ближайший из 8 в Mesher.
const SMOOTH_MATERIALS_8 = ['grass', 'rock', 'sand', 'snow', 'dirt', 'water', 'wood', 'glacier'];
const activeMaterials = terrainMode === 'smooth'
? TERRAIN_MATERIALS.filter((m) => SMOOTH_MATERIALS_8.includes(m.id))
: TERRAIN_MATERIALS;
return (
<div className={cl.panel}>
{/* Заголовок панели */}
<div className={cl.header}>
<button
type="button"
onClick={() => setTerrainMode(null)}
style={{
background: 'transparent', border: 'none', color: '#aac3ff',
cursor: 'pointer', padding: '2px 0', fontSize: 12,
textAlign: 'left',
}}
>
<Icon name="arrow-left" size={13} /> Назад к выбору типа
</button>
<div className={cl.headerTitle} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon emoji={terrainMode === 'smooth' ? '🌄' : '🟩'} size={15} />
{terrainMode === 'smooth' ? 'Гладкий ландшафт' : 'Воксели'}
</div>
<div className={cl.headerHint}>
{terrainMode === 'smooth'
? 'Кисти редактируют гладкую поверхность'
: 'Создавайте холмы, реки и пещеры'}
</div>
</div>
{/* Сетка инструментов — 4 столбца, как в Roblox Studio */}
<div className={cl.toolsGrid}>
{activeTools.map((t) => {
const Icon = t.icon;
const active = tool === t.id;
return (
<button
key={t.id}
type="button"
className={`${cl.toolBtn} ${active ? cl.toolBtnActive : ''}`}
onClick={() => handleTool(t.id)}
title={t.desc}
>
<div className={cl.toolIcon}>
<Icon />
</div>
<div className={cl.toolLabel}>{t.label}</div>
</button>
);
})}
</div>
{/* Разделитель */}
<div className={cl.divider} />
{/* Секция «Материал» — показывается только для инструментов
* которые ставят/красят. Для erase/smooth/select/transform/plant —
* палитра не нужна. */}
{!new Set([
'erase', 'smooth', 'select', 'transform',
'plantGrass', 'plantFlower', 'plantMushroom', 'plantTree',
'pickDeco',
]).has(tool) && (
<div className={cl.section}>
<div className={cl.sectionTitle}>Материал</div>
{terrainMode === 'smooth' && (
<div style={{ fontSize: 10, color: '#7aa3ff', padding: '0 0 6px', lineHeight: 1.4 }}>
Гладкий ландшафт поддерживает 8 материалов. Остальные
материалы доступны только в режиме «Воксели».
</div>
)}
<div className={cl.materialsGrid}>
{activeMaterials.map((m) => (
<button
key={m.id}
type="button"
className={`${cl.matBtn} ${material === m.id ? cl.matBtnActive : ''}`}
// Fallback цвет — если PNG не загрузился (404), плашка останется
// окрашенной и юзер сможет понять что выбрал.
style={{ background: m.color }}
onClick={() => handleMaterial(m.id)}
title={m.label}
>
{/* Превью текстуры. image-rendering: pixelated даёт sharp
* pixel-art-стиль (как в Minecraft), без размытия при scale. */}
<img
src={m.preview}
alt=""
className={cl.matPreview}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<span className={cl.matLabel}>{m.label}</span>
</button>
))}
</div>
</div>
)}
{/* Разделитель */}
<div className={cl.divider} />
{/* Секция «Кисть» — slider'ы размера и силы. Пока без onChange-логики
* (только локальный state). */}
<div className={cl.section}>
<div className={cl.sectionTitle}>Кисть</div>
<div className={cl.row}>
<label className={cl.rowLabel}>Размер</label>
<input
type="range"
className={cl.slider}
min="1" max="32" step="1"
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
/>
<span className={cl.rowValue}>{brushSize}</span>
</div>
<div className={cl.row}>
<label className={cl.rowLabel}>Сила</label>
<input
type="range"
className={cl.slider}
min="1" max="100" step="1"
value={strength}
onChange={(e) => setStrength(Number(e.target.value))}
/>
<span className={cl.rowValue}>{strength}</span>
</div>
{/* Форма кисти — переключатель сфера/куб/цилиндр. Иконки
* пока эмодзи, под будущую логику. */}
<div className={cl.row}>
<label className={cl.rowLabel}>Форма</label>
<div className={cl.shapeBtns}>
<button type="button"
className={`${cl.shapeBtn} ${shape === 'sphere' ? cl.shapeBtnActive : ''}`}
onClick={() => handleShape('sphere')}
title="Сфера"></button>
<button type="button"
className={`${cl.shapeBtn} ${shape === 'cube' ? cl.shapeBtnActive : ''}`}
onClick={() => handleShape('cube')}
title="Куб"></button>
<button type="button"
className={`${cl.shapeBtn} ${shape === 'cylinder' ? cl.shapeBtnActive : ''}`}
onClick={() => handleShape('cylinder')}
title="Цилиндр"></button>
</div>
</div>
</div>
{/* Разделитель */}
<div className={cl.divider} />
{/* Секция «Действия» — Undo/Redo/Очистить */}
<div className={cl.section}>
<div className={cl.sectionTitle}>Действия</div>
<div className={cl.actionsRow}>
<button
type="button"
className={cl.actionBtn}
onClick={() => onAction?.('undo')}
title="Откатить последний мазок (Ctrl+Z)"
>
Назад
</button>
<button
type="button"
className={cl.actionBtn}
onClick={() => onAction?.('redo')}
title="Вернуть откат (Ctrl+Y)"
>
Вперёд
</button>
</div>
<button
type="button"
className={`${cl.actionBtn} ${cl.actionBtnDanger}`}
onClick={() => onAction?.('clear-terrain')}
title="Удалить весь ландшафт (модели и блоки остаются)"
>
Очистить весь ландшафт
</button>
{/* Снять карту высот — только для гладкого ландшафта.
* Делает raycast по реальному мешу поверхности и
* скачивает JSON с точными высотами. Нужно для точного
* размещения объектов/блоков на земле. */}
{terrainMode === 'smooth' && (
<button
type="button"
className={cl.actionBtn}
onClick={() => onAction?.('export-heightmap')}
title="Снять точную карту высот поверхности и скачать JSON"
style={{ marginTop: 6 }}
>
<Icon name="ruler" size={13} /> Снять карту высот
</button>
)}
</div>
{/* Подсказка по hotkey'ам — школьники быстро освоятся если видят. */}
<div className={cl.hotkeysHint}>
<div><b>19, 0</b> выбор материала</div>
<div><b>[ ]</b> размер кисти</div>
<div><b>Ctrl+Z / Ctrl+Y</b> — отмена / возврат</div>
<div><b>Shift+ЛКМ</b> стереть</div>
</div>
</div>
);
}