From f6828aad2cf2a50dc118776019a6f7931442b1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=98=D0=9D?= Date: Sun, 31 May 2026 13:26:52 +0300 Subject: [PATCH] =?UTF-8?q?feat(09):=20per-face=20UV=20studs=20(=D1=82?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=B8=D0=BD=D0=B3=20=D0=BF=D0=BE=20=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D1=8F=D0=BC)=20+=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20studs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Тайлинг studs на кубе через faceUV (per-face) — кружки одного размера на всех гранях, не растягиваются на длинной стороне (баг на брусе 10×1×1). _studsCubeFaceUV считает UV каждой грани по её реальным размерам. - studDensity — плотность кружков (множитель): инспектор «Размер studs» Крупные(0.5)/Средние(1)/Мелкие(2)/Меньше(4). Для пола мелкие, для кирпича крупные. Проброс через data/_studsDims/faceUV/_studsTiling, сериализация, updateInstance(patch.studDensity)→пересоздание меша. Co-Authored-By: Claude Opus 4.8 --- public/dev-studstest.json | 1 + src/editor/InspectorPanel.jsx | 34 ++++++++++ src/editor/engine/PrimitiveManager.js | 97 +++++++++++++++++++++------ 3 files changed, 111 insertions(+), 21 deletions(-) create mode 100644 public/dev-studstest.json diff --git a/public/dev-studstest.json b/public/dev-studstest.json new file mode 100644 index 0000000..e85bc0f --- /dev/null +++ b/public/dev-studstest.json @@ -0,0 +1 @@ +{"version": 1, "scene": {"blocks": [], "models": [], "primitives": [{"id": 1, "type": "cube", "x": 0, "y": 1, "z": 0, "sx": 10, "sy": 1, "sz": 1, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#e8e8e8", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Длинный брус", "folderId": null}, {"id": 2, "type": "cube", "x": 0, "y": 1, "z": 4, "sx": 2, "sy": 2, "sz": 2, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#3a7aff", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Куб 2x2", "folderId": null}, {"id": 3, "type": "cube", "x": 6, "y": 1, "z": 2, "sx": 1, "sy": 1, "sz": 6, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "color": "#e07a30", "material": "studs", "canCollide": true, "visible": true, "anchored": true, "mass": 1, "name": "Брус по Z", "folderId": null}], "userModels": [], "terrain": null, "robloxTerrain": null, "decorations": [], "folders": [], "gui": [], "inventory": [], "spawnPoint": {"x": 0, "y": 1.7, "z": -6}, "playerModelType": "character-a", "worldSize": 100, "floorEnabled": true, "scripts": []}, "editorCamera": null, "settings": {}, "__devName": "Studs-тест"} \ No newline at end of file diff --git a/src/editor/InspectorPanel.jsx b/src/editor/InspectorPanel.jsx index a602e8a..28d07f5 100644 --- a/src/editor/InspectorPanel.jsx +++ b/src/editor/InspectorPanel.jsx @@ -317,6 +317,7 @@ const InspectorPanel = ({ const [localSz, setLocalSz] = useState(''); const [localColor, setLocalColor] = useState('#888888'); const [localMaterial, setLocalMaterial] = useState('matte'); + const [localStudDensity, setLocalStudDensity] = useState(1); // плотность studs const [localCanCollide, setLocalCanCollide] = useState(true); const [localVisible, setLocalVisible] = useState(true); const [localAnchored, setLocalAnchored] = useState(true); @@ -362,6 +363,7 @@ const InspectorPanel = ({ setLocalSz((selection.sz || 1).toFixed(2)); setLocalColor(selection.color || '#888888'); setLocalMaterial(selection.material || 'matte'); + setLocalStudDensity(selection.studDensity || 1); setLocalCanCollide(selection.canCollide !== false); setLocalVisible(selection.visible !== false); setLocalAnchored(selection.anchored !== false); @@ -1755,6 +1757,38 @@ const InspectorPanel = ({ + {/* Размер studs — плотность лего-кружков (только для material studs) */} + {localMaterial === 'studs' && ( +
+
Размер studs
+
+ {[ + { label: 'Крупные', d: 0.5 }, + { label: 'Средние', d: 1 }, + { label: 'Мелкие', d: 2 }, + { label: 'Меньше', d: 4 }, + ].map(opt => ( + + ))} +
+
+ )} + {/* Текстура — своя картинка на гранях примитива */}
diff --git a/src/editor/engine/PrimitiveManager.js b/src/editor/engine/PrimitiveManager.js index 56ca51d..54dfd52 100644 --- a/src/editor/engine/PrimitiveManager.js +++ b/src/editor/engine/PrimitiveManager.js @@ -19,7 +19,7 @@ * При касании игроком обновляет spawnPoint сцены. */ import { - MeshBuilder, StandardMaterial, Color3, Vector3, PointLight, + MeshBuilder, StandardMaterial, Color3, Vector3, Vector4, PointLight, Mesh, VertexData, Texture, DynamicTexture, } from '@babylonjs/core'; import { getPrimitiveType } from './PrimitiveTypes'; @@ -52,8 +52,10 @@ function _getStudsTextures(scene) { * число кружков в самой текстуре (STUDS_GRID). * Для куба/плоскости тайлинг прямой; для сферы/цилиндра — приближённый. */ -function _studsTiling(type, sx, sy, sz) { - const f = STUD_UNIT * STUDS_GRID; +function _studsTiling(type, sx, sy, sz, density) { + // density — множитель плотности кружков (1=стандарт, 2=вдвое мельче/чаще). + const d = density && density > 0 ? density : 1; + const f = (STUD_UNIT * STUDS_GRID) / d; // По умолчанию (cube/plane/wedge) — горизонтальный размер по U, вертикаль по V. let u = Math.max(sx, sz) / f; let v = sy / f; @@ -70,6 +72,31 @@ function _studsTiling(type, sx, sy, sz) { } return { u: Math.max(0.25, u), v: Math.max(0.25, v) }; } +/** + * faceUV для куба со studs — КАЖДАЯ грань тайлится по СВОИМ реальным размерам, + * чтобы кружки были одного размера на всех гранях (не растягивались на длинных). + * Грани CreateBox: 0=front(z-) 1=back(z+) 2=right(x+) 3=left(x-) 4=top(y+) 5=bottom(y-). + * front/back → ширина=sx, высота=sy + * left/right → ширина=sz, высота=sy + * top/bottom → ширина=sx, высота=sz + * UV-диапазон грани = (0,0)..(кол-во_studs_по_ширине, кол-во_по_высоте). + */ +function _studsCubeFaceUV(sx, sy, sz, density) { + const d = density && density > 0 ? density : 1; + const f = (STUD_UNIT * STUDS_GRID) / d; + const nx = Math.max(0.25, sx / f); // studs вдоль X + const ny = Math.max(0.25, sy / f); // studs вдоль Y + const nz = Math.max(0.25, sz / f); // studs вдоль Z + // Vector4(u0, v0, u1, v1) + return [ + new Vector4(0, 0, nx, ny), // front (z-): X×Y + new Vector4(0, 0, nx, ny), // back (z+): X×Y + new Vector4(0, 0, nz, ny), // right (x+): Z×Y + new Vector4(0, 0, nz, ny), // left (x-): Z×Y + new Vector4(0, 0, nx, nz), // top (y+): X×Z + new Vector4(0, 0, nx, nz), // bottom (y-): X×Z + ]; +} export class PrimitiveManager { constructor(scene) { @@ -111,6 +138,8 @@ export class PrimitiveManager { const isGlowingGd = isGdKind; const isGdSpike = typeDef.kind === 'gd_spike'; const material = opts.material ?? (isGlowingGd ? 'neon' : 'matte'); + // studDensity — плотность кружков studs (1=стандарт, >1 мельче/чаще). + const studDensity = Number.isFinite(opts.studDensity) ? opts.studDensity : 1; // canCollide: для всех GD-сущностей и шипов — false (игрок проходит сквозь, логика по дистанции) const canCollide = opts.canCollide !== false && !isGdKind && !isGdSpike; const visible = opts.visible !== false; @@ -128,7 +157,7 @@ export class PrimitiveManager { const rotationY = opts.rotationY ?? 0; const rotationZ = opts.rotationZ ?? 0; - const mesh = this._createMeshForType(typeDef, id, sx, sy, sz); + const mesh = this._createMeshForType(typeDef, id, sx, sy, sz, material, studDensity); mesh.position = new Vector3(x, y, z); mesh.rotation = new Vector3(rotationX, rotationY, rotationZ); mesh.isPickable = true; @@ -150,7 +179,7 @@ export class PrimitiveManager { id, mesh, type, x, y, z, sx, sy, sz, rotationX, rotationY, rotationZ, color, material, canCollide, visible, anchored, mass, - textureAsset, + textureAsset, studDensity, // locked — объект защищён от выделения/перемещения в редакторе // (Фаза 5.11). На геймплей не влияет. locked: opts.locked === true, @@ -158,7 +187,7 @@ export class PrimitiveManager { folderId: opts.folderId ?? null, }; // Размеры для тайлинга studs-материала (читается в _applyMaterial). - mesh._studsDims = { type, sx, sy, sz }; + mesh._studsDims = { type, sx, sy, sz, density: studDensity }; this._applyMaterial(mesh, typeDef, color, material); this._applyVisible(mesh, visible, typeDef); // Пользовательская текстура — поверх базового материала. @@ -236,13 +265,17 @@ export class PrimitiveManager { } /** Создать базовый mesh нужной формы (без материала). */ - _createMeshForType(typeDef, id, sx, sy, sz) { + _createMeshForType(typeDef, id, sx, sy, sz, material, studDensity) { const name = `prim_${typeDef.id}_${id}`; switch (typeDef.id) { case 'cube': - case 'trigger': - return MeshBuilder.CreateBox(name, - { width: sx, height: sy, depth: sz }, this.scene); + case 'trigger': { + const boxOpts = { width: sx, height: sy, depth: sz }; + // studs — per-face UV, чтобы кружки были одного размера на всех + // гранях (не растягивались на длинной стороне). + if (material === 'studs') boxOpts.faceUV = _studsCubeFaceUV(sx, sy, sz, studDensity); + return MeshBuilder.CreateBox(name, boxOpts, this.scene); + } case 'sphere': return MeshBuilder.CreateSphere(name, { diameterX: sx, diameterY: sy, diameterZ: sz, segments: 24 }, this.scene); @@ -490,9 +523,16 @@ export class PrimitiveManager { const dt = tex.diffuse.clone(); const nt = tex.normal.clone(); const dims = mesh._studsDims || { type: 'cube', sx: 1, sy: 1, sz: 1 }; - const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz); - dt.uScale = nt.uScale = tile.u; - dt.vScale = nt.vScale = tile.v; + // Куб/триггер тайлятся через faceUV геометрии (uScale=1) — кружки + // одного размера на всех гранях. Остальные формы — через uScale. + if (dims.type === 'cube' || dims.type === 'trigger') { + dt.uScale = nt.uScale = 1; + dt.vScale = nt.vScale = 1; + } else { + const tile = _studsTiling(dims.type, dims.sx, dims.sy, dims.sz, dims.density); + dt.uScale = nt.uScale = tile.u; + dt.vScale = nt.vScale = tile.v; + } mat.diffuseTexture = dt; mat.bumpTexture = nt; const sc = Color3.FromHexString(color || '#cccccc'); @@ -646,6 +686,12 @@ export class PrimitiveManager { if (patch.sx !== undefined) { data.sx = patch.sx; scaleChanged = true; } if (patch.sy !== undefined) { data.sy = patch.sy; scaleChanged = true; } if (patch.sz !== undefined) { data.sz = patch.sz; scaleChanged = true; } + // Плотность studs (мелкие/крупные кружки) — требует пересоздания меша + // (faceUV для куба зашит в геометрию). + if (patch.studDensity !== undefined) { + data.studDensity = Number.isFinite(patch.studDensity) ? patch.studDensity : 1; + scaleChanged = true; + } if (scaleChanged) { // Поскольку MeshBuilder уже создал mesh с базовыми размерами, // изменения через scaling кажутся правильными. Простой способ — @@ -794,7 +840,7 @@ export class PrimitiveManager { const oldMat = oldMesh.material; const typeDef = getPrimitiveType(data.type); - const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz); + const newMesh = this._createMeshForType(typeDef, data.id, data.sx, data.sy, data.sz, data.material, data.studDensity); newMesh.position = oldPos; if (oldRot) newMesh.rotation = oldRot; newMesh.material = oldMat; @@ -807,15 +853,22 @@ export class PrimitiveManager { catch (e) { /* ignore */ } data.mesh = newMesh; - newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz }; + newMesh._studsDims = { type: data.type, sx: data.sx, sy: data.sy, sz: data.sz, density: data.studDensity }; // studs-материал: пересчитать тайлинг под новый размер меша. + // Куб уже пересоздан с новым faceUV (тайлинг в геометрии) — uScale=1. + // Для остальных форм пересчитываем uScale/vScale по размеру. if (data.material === 'studs' && oldMat && oldMat.diffuseTexture) { - const tile = _studsTiling(data.type, data.sx, data.sy, data.sz); - oldMat.diffuseTexture.uScale = tile.u; - oldMat.diffuseTexture.vScale = tile.v; - if (oldMat.bumpTexture) { - oldMat.bumpTexture.uScale = tile.u; - oldMat.bumpTexture.vScale = tile.v; + if (data.type === 'cube' || data.type === 'trigger') { + oldMat.diffuseTexture.uScale = oldMat.diffuseTexture.vScale = 1; + if (oldMat.bumpTexture) oldMat.bumpTexture.uScale = oldMat.bumpTexture.vScale = 1; + } else { + const tile = _studsTiling(data.type, data.sx, data.sy, data.sz, data.studDensity); + oldMat.diffuseTexture.uScale = tile.u; + oldMat.diffuseTexture.vScale = tile.v; + if (oldMat.bumpTexture) { + oldMat.bumpTexture.uScale = tile.u; + oldMat.bumpTexture.vScale = tile.v; + } } } } @@ -870,6 +923,8 @@ export class PrimitiveManager { ...(d.locked ? { locked: true } : {}), // id пользовательской текстуры (картинка из AssetManager). ...(d.textureAsset ? { textureAsset: d.textureAsset } : {}), + // Плотность studs (если не 1) — мелкие/крупные кружки. + ...(d.studDensity && d.studDensity !== 1 ? { studDensity: d.studDensity } : {}), // Параметры лампы (только для type='light', иначе undefined) ...(d.light ? { brightness: d.brightness, range: d.range } : {}), // Параметр эмиттера (только для type='emitter')