studio/src/editor/engine/robloxterrain/SmoothBrushes.js
МИН 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

250 lines
12 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.

/**
* SmoothBrushes — кисти для редактирования гладкого ландшафта (DensityGrid).
*
* Все операции работают со СФЕРОЙ в мировых координатах + строгая привязка
* к density-grid (4м/cell). Возвращают Set<chunkKey> затронутых чанков,
* чтобы RobloxTerrain пересобрал только их (а не всю карту).
*
* Поддерживаемые кисти (как в Roblox Studio):
* - sculptUp: добавить материал (поднять рельеф). Density += strength.
* - sculptDown: убрать материал (вырыть). Density -= strength.
* - smooth: усреднить density с соседями (laplacian filter).
* - paint: заменить материал в solid-ячейках, density не меняем.
* - flatten: выровнять по плоскости Y=target — поднять/опустить density.
* - fill: залить сферу density=255 материалом.
* - erase: обнулить density в сфере.
*/
import { CELL_SIZE, DENSITY_THRESHOLD } from './DensityGrid';
/** Размер chunk'а — должен совпадать с RobloxTerrain.CHUNK_SIZE. */
const CHUNK_SIZE = 16;
function worldToCell(w) {
return Math.floor(w / CELL_SIZE);
}
/** Ключ chunk'а из LOCAL cell-индекса (внутри grid). */
function localCellToChunkKey(lx, ly, lz) {
const cx = Math.floor(lx / CHUNK_SIZE);
const cy = Math.floor(ly / CHUNK_SIZE);
const cz = Math.floor(lz / CHUNK_SIZE);
return `${cx},${cy},${cz}`;
}
/**
* Перебор cells в сфере с smoothstep-влиянием.
* grid.origin — global origin. Координаты gx,gy,gz в callback — LOCAL индексы
* (внутри grid 0..size). Используем local чтобы grid.set / grid.getDensity
* работали правильно.
*/
function forCellsInSphere(grid, center, radius, callback) {
const dirty = new Set();
const r2 = radius * radius;
// bbox в LOCAL индексах с запасом +1
const minGxL = worldToCell(center.x - radius) - grid.origin.x - 1;
const maxGxL = worldToCell(center.x + radius) - grid.origin.x + 1;
const minGyL = worldToCell(center.y - radius) - grid.origin.y - 1;
const maxGyL = worldToCell(center.y + radius) - grid.origin.y + 1;
const minGzL = worldToCell(center.z - radius) - grid.origin.z - 1;
const maxGzL = worldToCell(center.z + radius) - grid.origin.z + 1;
for (let gzL = minGzL; gzL <= maxGzL; gzL++) {
for (let gyL = minGyL; gyL <= maxGyL; gyL++) {
for (let gxL = minGxL; gxL <= maxGxL; gxL++) {
// Центр cell в мире
const wx = (grid.origin.x + gxL + 0.5) * CELL_SIZE;
const wy = (grid.origin.y + gyL + 0.5) * CELL_SIZE;
const wz = (grid.origin.z + gzL + 0.5) * CELL_SIZE;
const dx = wx - center.x;
const dy = wy - center.y;
const dz = wz - center.z;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 > r2) continue;
const t = Math.sqrt(d2) / radius;
const influence = 1 - t * t * (3 - 2 * t);
const changed = callback(gxL, gyL, gzL, influence);
if (changed) dirty.add(localCellToChunkKey(gxL, gyL, gzL));
}
}
}
return dirty;
}
/** Sculpt Up — добавляет density (поднять / нарастить).
* Важный фикс для низкой силы: если cell ПУСТАЯ (oldD<10) и попала в зону
* влияния (inf > 0.1) — сразу делаем density >= threshold (128). Иначе
* низкая strength не давала cell достичь порога видимости → пользователь
* кликал-кликал, density рос на 1-2 за клик, но визуально ничего не было
* пока 128 не достиг (≈40 кликов в одну cell).
* С прыжком до threshold — первый клик СРАЗУ показывает результат, дальше
* растёт линейно от strength.
*/
export function brushSculptUp(grid, params) {
const { center, radius, strength = 80, material = 'grass' } = params;
// strength → нормализованная sNorm 0..1 (60..400 → 0..1).
const sNorm = Math.max(0, Math.min(1, (strength - 60) / 340));
// === Эффективный радиус: основа влияния силы ===
// sNorm=0 → effectiveRadius = 0.35×radius (маленький бугорок)
// sNorm=1 → effectiveRadius = 1.0×radius (полный шар)
// Это даёт реальное визуальное отличие — холм РАЗНОЙ ВЫСОТЫ:
// слабая сила — короткий бугор, сильная — полный купол.
const effRadius = radius * (0.35 + sNorm * 0.65);
return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => {
// Вычисляем РАССТОЯНИЕ cell-центра до центра кисти (в метрах).
// Если оно > effRadius — пропускаем (cell вне effective-зоны).
const wx = (grid.origin.x + gx + 0.5) * CELL_SIZE;
const wy = (grid.origin.y + gy + 0.5) * CELL_SIZE;
const wz = (grid.origin.z + gz + 0.5) * CELL_SIZE;
const dx = wx - center.x;
const dy = wy - center.y;
const dz = wz - center.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist > effRadius) return false;
// Внутри effRadius — плавный градиент density: центр=255, край=threshold.
const t = dist / effRadius;
const target = 255 - (255 - DENSITY_THRESHOLD) * t * t; // мягкий falloff
const oldD = grid.getDensity(gx, gy, gz);
if (oldD >= target) return false;
const newD = Math.round(target);
if (newD === oldD) return false;
const curMat = grid.getMatId(gx, gy, gz);
const matKey = curMat === 0 ? material : null;
grid.set(gx, gy, gz, newD, matKey);
return true;
});
}
/** Sculpt Down — симметрично sculptUp: effective radius растёт с силой. */
export function brushSculptDown(grid, params) {
const { center, radius, strength = 80 } = params;
const sNorm = Math.max(0, Math.min(1, (strength - 60) / 340));
const effRadius = radius * (0.35 + sNorm * 0.65);
return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => {
const wx = (grid.origin.x + gx + 0.5) * CELL_SIZE;
const wy = (grid.origin.y + gy + 0.5) * CELL_SIZE;
const wz = (grid.origin.z + gz + 0.5) * CELL_SIZE;
const dx = wx - center.x;
const dy = wy - center.y;
const dz = wz - center.z;
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist > effRadius) return false;
const oldD = grid.getDensity(gx, gy, gz);
if (oldD === 0) return false;
// Center=0, край=threshold-2 (мягкий край ямы).
const t = dist / effRadius;
const target = 0 + (DENSITY_THRESHOLD - 2) * t * t;
if (oldD <= target) return false;
const newD = Math.max(0, Math.round(target));
if (newD === oldD) return false;
grid.set(gx, gy, gz, newD, null);
return true;
});
}
/** Smooth — усредняет density с соседями. */
export function brushSmooth(grid, params) {
const { center, radius, strength = 50 } = params;
const updates = [];
forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => {
const curD = grid.getDensity(gx, gy, gz);
const sumD =
grid.getDensity(gx - 1, gy, gz) + grid.getDensity(gx + 1, gy, gz) +
grid.getDensity(gx, gy - 1, gz) + grid.getDensity(gx, gy + 1, gz) +
grid.getDensity(gx, gy, gz - 1) + grid.getDensity(gx, gy, gz + 1);
const avgD = sumD / 6;
const blend = Math.min(1, (strength / 255) * inf);
const newD = Math.round(curD * (1 - blend) + avgD * blend);
if (newD !== curD) updates.push([gx, gy, gz, newD]);
return false;
});
const dirty = new Set();
for (const [gx, gy, gz, newD] of updates) {
grid.set(gx, gy, gz, newD, null);
dirty.add(localCellToChunkKey(gx, gy, gz));
}
return dirty;
}
/** Paint — меняет материал в solid-ячейках, density не трогаем. */
export function brushPaint(grid, params) {
const { center, radius, material = 'grass' } = params;
return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => {
const d = grid.getDensity(gx, gy, gz);
if (d < DENSITY_THRESHOLD) return false;
const curKey = grid.getMatKey(gx, gy, gz);
if (curKey === material) return false;
grid.set(gx, gy, gz, d, material);
return true;
});
}
/** Flatten — выровнять поверхность по плоскости Y=targetY. */
export function brushFlatten(grid, params) {
const { center, radius, strength = 80, material = 'grass', targetY = center.y } = params;
return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => {
const cellY = (grid.origin.y + gy + 0.5) * CELL_SIZE;
const oldD = grid.getDensity(gx, gy, gz);
let newD;
if (cellY < targetY - CELL_SIZE * 0.5) {
newD = Math.min(255, oldD + strength * inf);
} else if (cellY > targetY + CELL_SIZE * 0.5) {
newD = Math.max(0, oldD - strength * inf);
} else {
const target = DENSITY_THRESHOLD;
const delta = (target - oldD) * inf * (strength / 255);
newD = Math.round(Math.max(0, Math.min(255, oldD + delta)));
}
newD = newD | 0;
if (newD === oldD) return false;
const curMat = grid.getMatId(gx, gy, gz);
const matKey = curMat === 0 ? material : null;
grid.set(gx, gy, gz, newD, matKey);
return true;
});
}
/** Fill — жёстко заполняет сферу solid-density (255). */
export function brushFill(grid, params) {
const { center, radius, material = 'grass' } = params;
return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => {
if (inf < 0.05) return false;
const oldD = grid.getDensity(gx, gy, gz);
const curKey = grid.getMatKey(gx, gy, gz);
if (oldD === 255 && curKey === material) return false;
grid.set(gx, gy, gz, 255, material);
return true;
});
}
/** Erase — обнуляет density. */
export function brushErase(grid, params) {
const { center, radius } = params;
return forCellsInSphere(grid, center, radius, (gx, gy, gz, inf) => {
const oldD = grid.getDensity(gx, gy, gz);
if (oldD === 0) return false;
const newD = Math.max(0, oldD - 255 * inf) | 0;
if (newD === oldD) return false;
grid.set(gx, gy, gz, newD, null);
return true;
});
}
/**
* Главный API — диспетчер по типу кисти.
* @returns {Set<string>} dirty chunks
*/
export function applyBrush(grid, brushType, params) {
switch (brushType) {
case 'sculptUp': return brushSculptUp(grid, params);
case 'sculptDown': return brushSculptDown(grid, params);
case 'smooth': return brushSmooth(grid, params);
case 'paint': return brushPaint(grid, params);
case 'flatten': return brushFlatten(grid, params);
case 'fill': return brushFill(grid, params);
case 'erase': return brushErase(grid, params);
default:
console.warn('[SmoothBrushes] unknown brush:', brushType);
return new Set();
}
}