Kgshop commited on
Commit
a9289e8
·
verified ·
1 Parent(s): 1727a09

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +365 -308
app.py CHANGED
@@ -97,7 +97,7 @@ EDITOR_TEMPLATE = '''
97
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
98
  <title>3D Level Designer</title>
99
  <style>
100
- body { margin: 0; overflow: hidden; background-color: #111; color: white; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
101
  canvas { display: block; }
102
  #ui-panel {
103
  position: absolute;
@@ -105,31 +105,47 @@ EDITOR_TEMPLATE = '''
105
  left: 10px;
106
  background: rgba(20, 20, 25, 0.85);
107
  backdrop-filter: blur(10px);
 
108
  padding: 15px;
109
- border-radius: 8px;
110
- border: 1px solid #444;
111
- width: 280px;
 
112
  max-height: calc(100vh - 20px);
113
  overflow-y: auto;
114
- display: flex;
115
- flex-direction: column;
116
- gap: 15px;
 
 
 
 
 
 
 
 
 
117
  }
118
- .ui-group { padding: 10px; border: 1px solid #333; border-radius: 6px; }
119
- h3 { margin-top: 0; margin-bottom: 10px; font-size: 1.1em; color: #00aaff; border-bottom: 1px solid #444; padding-bottom: 8px;}
120
- label { display: block; margin-bottom: 5px; font-size: 0.9em; }
121
- input, select, button { width: 100%; padding: 8px; box-sizing: border-box; background: #222; border: 1px solid #555; color: white; border-radius: 4px; font-family: inherit; font-size: 1em; }
122
- button { background: #0077cc; border: none; cursor: pointer; font-weight: bold; margin-top: 10px; transition: background-color 0.2s; }
123
  button:hover { background: #0099ff; }
124
- button.active { background: #00aaff; box-shadow: 0 0 8px #00aaff; }
125
  .slider-container { margin-top: 10px; }
126
- input[type="range"] { padding: 0; }
127
- .tool-selection, .texture-selection, .foliage-selection { display: flex; flex-wrap: wrap; gap: 8px; }
128
- .tool-selection button, .texture-selection button, .foliage-selection button { width: auto; flex-grow: 1; margin-top: 0; }
129
- #loading-spinner { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); border: 8px solid #f3f3f3; border-top: 8px solid #3498db; border-radius: 50%; width: 60px; height: 60px; animation: spin 1s linear infinite; display: none; z-index: 100; }
 
 
 
 
 
 
 
 
 
130
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
131
  @media (max-width: 768px) {
132
- #ui-panel { width: calc(100% - 20px); max-height: 50vh; }
 
 
133
  }
134
  </style>
135
  </head>
@@ -160,131 +176,130 @@ EDITOR_TEMPLATE = '''
160
  </div>
161
 
162
  <div class="ui-group">
163
- <h3>Инструменты</h3>
164
- <div class="tool-selection">
165
- <button id="tool-sculpt" class="active">Лепка</button>
166
- <button id="tool-paint">Рисовать</button>
167
- <button id="tool-foliage">Объекты</button>
 
168
  </div>
169
  </div>
170
-
171
- <div id="sculpt-settings" class="ui-group">
172
- <h3>Настройки лепки</h3>
173
- <div>
174
- <label><input type="radio" name="brush-mode" value="raise" checked> Поднять</label>
175
- <label><input type="radio" name="brush-mode" value="lower"> Опустить</label>
176
- </div>
177
- <div class="slider-container">
178
- <label for="brush-strength">Сила: <span id="brush-strength-value">0.5</span></label>
179
- <input type="range" id="brush-strength" min="0.1" max="2" step="0.1" value="0.5">
180
  </div>
181
  </div>
182
-
183
- <div id="paint-settings" class="ui-group" style="display: none;">
184
- <h3>Текстуры</h3>
185
- <div class="texture-selection">
186
- <button class="texture-btn" data-texture-id="0">Трава</button>
187
- <button class="texture-btn" data-texture-id="1">Земля</button>
188
- <button class="texture-btn" data-texture-id="2">Камни</button>
189
- <button class="texture-btn" data-texture-id="3">Песок</button>
 
 
 
 
190
  </div>
191
  </div>
192
 
193
- <div id="foliage-settings" class="ui-group" style="display: none;">
194
- <h3>Растительность</h3>
195
- <div class="foliage-selection">
196
- <button class="foliage-btn" data-foliage-type="grass">Трава</button>
197
- </div>
198
- <div class="slider-container">
199
- <label for="foliage-density">Плотность: <span id="foliage-density-value">5</span></label>
200
- <input type="range" id="foliage-density" min="1" max="20" step="1" value="5">
201
- </div>
202
- <div class="slider-container">
203
- <label for="foliage-scale">Масштаб: <span id="foliage-scale-value">1</span></label>
204
- <input type="range" id="foliage-scale" min="0.5" max="3" step="0.1" value="1">
205
  </div>
206
  </div>
207
-
208
  <div class="ui-group">
209
- <h3>Кисть</h3>
210
  <div class="slider-container">
211
  <label for="brush-size">Размер: <span id="brush-size-value">10</span></label>
212
  <input type="range" id="brush-size" min="1" max="50" value="10">
213
  </div>
 
 
 
 
214
  </div>
215
  </div>
216
 
217
  <div id="loading-spinner"></div>
218
 
219
  <script type="importmap">
220
- { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js", "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" } }
 
 
 
 
 
221
  </script>
222
 
223
  <script type="module">
224
  import * as THREE from 'three';
225
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
226
 
227
- let scene, camera, renderer, controls, terrainMesh, brushHelper;
228
  let raycaster = new THREE.Raycaster();
229
  let mouse = new THREE.Vector2();
230
- let isSculpting = false;
231
 
232
- let terrainDimensions = { width: 100, height: 100, segmentsX: 100, segmentsY: 100 };
233
- let currentTool = 'sculpt';
234
- let selectedTextureId = 0;
235
- let selectedFoliageType = 'grass';
236
- let terrainTextures = [];
237
- let splatMap, splatMapTexture;
238
-
239
- const foliage = {};
240
- const MAX_FOLIAGE_INSTANCES = 50000;
 
 
 
 
 
241
 
242
  const vertexShader = `
243
  varying vec2 vUv;
244
- varying vec3 vNormal;
245
- varying vec3 vWorldPosition;
246
  void main() {
247
  vUv = uv;
248
- vNormal = normalMatrix * normal;
249
- vec4 worldPosition = modelMatrix * vec4(position, 1.0);
250
- vWorldPosition = worldPosition.xyz;
251
- gl_Position = projectionMatrix * viewMatrix * worldPosition;
252
  }
253
  `;
254
 
255
  const fragmentShader = `
256
- uniform sampler2D uSplatMap;
257
- uniform sampler2D uTexture1; // Grass
258
- uniform sampler2D uTexture2; // Dirt
259
- uniform sampler2D uTexture3; // Rock
260
- uniform sampler2D uTexture4; // Sand
261
-
262
  varying vec2 vUv;
263
- varying vec3 vNormal;
264
- varying vec3 vWorldPosition;
265
-
266
  void main() {
267
- vec4 splatWeights = texture2D(uSplatMap, vUv);
 
 
 
 
 
268
 
269
- vec2 textureScale = vec2(30.0, 30.0);
270
- vec4 tex1 = texture2D(uTexture1, vUv * textureScale);
271
- vec4 tex2 = texture2D(uTexture2, vUv * textureScale);
272
- vec4 tex3 = texture2D(uTexture3, vUv * textureScale);
273
- vec4 tex4 = texture2D(uTexture4, vUv * textureScale);
274
-
275
- vec4 finalColor = tex1 * splatWeights.r + tex2 * splatWeights.g + tex3 * splatWeights.b + tex4 * splatWeights.a;
276
 
277
- vec3 lightDirection = normalize(vec3(1.0, 1.0, 0.5));
278
- float diffuse = max(0.0, dot(normalize(vNormal), lightDirection)) * 0.7 + 0.3;
279
-
280
- gl_FragColor = vec4(finalColor.rgb * diffuse, 1.0);
281
  }
282
  `;
283
-
284
  function init() {
285
  scene = new THREE.Scene();
286
  scene.background = new THREE.Color(0x87ceeb);
287
- scene.fog = new THREE.Fog(0x87ceeb, 200, 500);
288
 
289
  camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
290
  camera.position.set(50, 50, 50);
@@ -297,7 +312,7 @@ EDITOR_TEMPLATE = '''
297
 
298
  controls = new OrbitControls(camera, renderer.domElement);
299
  controls.enableDamping = true;
300
- controls.maxPolarAngle = Math.PI / 2.1;
301
 
302
  const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 1.5);
303
  hemiLight.position.set(0, 200, 0);
@@ -314,65 +329,43 @@ EDITOR_TEMPLATE = '''
314
  dirLight.shadow.mapSize.height = 2048;
315
  scene.add(dirLight);
316
 
317
- const brushGeometry = new THREE.CylinderGeometry(1, 1, 100, 32, 1, true);
318
  const brushMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00, wireframe: true, transparent: true, opacity: 0.5 });
319
  brushHelper = new THREE.Mesh(brushGeometry, brushMaterial);
320
  brushHelper.visible = false;
321
  scene.add(brushHelper);
322
-
323
- loadTextures();
324
- createFoliageAssets();
325
-
326
  window.addEventListener('resize', onWindowResize);
327
- renderer.domElement.addEventListener('pointermove', onPointerMove);
328
- renderer.domElement.addEventListener('pointerdown', onPointerDown);
329
- renderer.domElement.addEventListener('pointerup', onPointerUp);
330
- renderer.domElement.addEventListener('pointerout', () => { brushHelper.visible = false; });
331
 
332
  setupUIListeners();
333
  animate();
334
  }
335
-
336
- function generateTexture(color1, color2, size = 256) {
337
- const canvas = document.createElement('canvas');
338
- canvas.width = size;
339
- canvas.height = size;
340
- const context = canvas.getContext('2d');
341
- context.fillStyle = color1;
342
- context.fillRect(0, 0, size, size);
343
- for (let i = 0; i < size * size / 4; i++) {
344
- context.fillStyle = `rgba(${color2.r}, ${color2.g}, ${color2.b}, ${Math.random() * 0.5})`;
345
- context.fillRect(Math.random() * size, Math.random() * size, 2, 2);
346
- }
347
- return new THREE.CanvasTexture(canvas);
348
- }
349
-
350
- function loadTextures() {
351
- terrainTextures.push(generateTexture('#3A5F0B', {r: 60, g: 120, b: 20})); // Grass
352
- terrainTextures.push(generateTexture('#654321', {r: 139, g: 69, b: 19})); // Dirt
353
- terrainTextures.push(generateTexture('#808080', {r: 105, g: 105, b: 105})); // Rock
354
- terrainTextures.push(generateTexture('#FADDAA', {r: 245, g: 222, b: 179})); // Sand
355
- terrainTextures.forEach(t => {
356
- t.wrapS = THREE.RepeatWrapping;
357
- t.wrapT = THREE.RepeatWrapping;
358
- });
359
- }
360
 
361
- function createFoliageAssets() {
362
- const grassMaterial = new THREE.MeshBasicMaterial({ color: 0x339933, side: THREE.DoubleSide, transparent: true, opacity: 0.8 });
363
- const planeGeom = new THREE.PlaneGeometry(1, 1);
364
- const grassBlade1 = new THREE.Mesh(planeGeom, grassMaterial);
365
- const grassBlade2 = grassBlade1.clone();
366
- grassBlade2.rotation.y = Math.PI / 2;
367
- const grassGeom = new THREE.BufferGeometry().copy(grassBlade1.geometry).merge(grassBlade2.geometry, grassBlade2.matrix);
 
 
368
 
369
- foliage['grass'] = {
370
- geometry: grassGeom,
371
- material: grassMaterial,
372
- mesh: new THREE.InstancedMesh(grassGeom, grassMaterial, MAX_FOLIAGE_INSTANCES)
373
- };
374
- foliage['grass'].mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
375
- scene.add(foliage['grass'].mesh);
 
 
376
  }
377
 
378
  function setupUIListeners() {
@@ -380,107 +373,135 @@ EDITOR_TEMPLATE = '''
380
  const width = parseInt(document.getElementById('terrain-width').value);
381
  const height = parseInt(document.getElementById('terrain-height').value);
382
  createTerrain(width, height);
383
- clearFoliage();
384
  });
385
 
386
  document.getElementById('save-project').addEventListener('click', saveProject);
387
  document.getElementById('load-project').addEventListener('click', loadProject);
388
-
389
  document.getElementById('brush-size').addEventListener('input', (e) => {
390
  document.getElementById('brush-size-value').textContent = e.target.value;
391
  updateBrushHelper();
392
  });
393
- document.getElementById('brush-strength').addEventListener('input', (e) => document.getElementById('brush-strength-value').textContent = e.target.value);
394
- document.getElementById('foliage-density').addEventListener('input', (e) => document.getElementById('foliage-density-value').textContent = e.target.value);
395
- document.getElementById('foliage-scale').addEventListener('input', (e) => document.getElementById('foliage-scale-value').textContent = e.target.value);
396
-
397
- document.getElementById('project-list').addEventListener('change', (e) => document.getElementById('project-name').value = e.target.value);
398
-
399
- document.querySelectorAll('.tool-selection button').forEach(btn => {
400
- btn.addEventListener('click', (e) => {
401
- document.querySelector('.tool-selection button.active').classList.remove('active');
402
- e.target.classList.add('active');
403
- currentTool = e.target.id.replace('tool-', '');
404
- updateUIToTool();
405
- });
406
  });
407
-
408
- document.querySelectorAll('.texture-btn').forEach(btn => {
409
- btn.addEventListener('click', (e) => {
410
- selectedTextureId = parseInt(e.target.dataset.textureId);
411
- console.log(`Texture selected: ${selectedTextureId}`);
412
- });
413
  });
414
 
415
- document.querySelectorAll('.foliage-btn').forEach(btn => {
416
- btn.addEventListener('click', (e) => {
417
- selectedFoliageType = e.target.dataset.foliageType;
 
 
 
418
  });
419
  });
420
  }
421
 
422
- function updateUIToTool() {
423
- document.getElementById('sculpt-settings').style.display = currentTool === 'sculpt' ? 'block' : 'none';
424
- document.getElementById('paint-settings').style.display = currentTool === 'paint' ? 'block' : 'none';
425
- document.getElementById('foliage-settings').style.display = currentTool === 'foliage' ? 'block' : 'none';
426
  }
427
-
428
- function showSpinner(show) { document.getElementById('loading-spinner').style.display = show ? 'block' : 'none'; }
429
 
430
- function createTerrain(width, height, heightData = null, splatData = null) {
431
- if (terrainMesh) { scene.remove(terrainMesh); terrainMesh.geometry.dispose(); terrainMesh.material.dispose(); }
 
 
 
 
432
 
433
- terrainDimensions = { width, height, segmentsX: Math.max(1, Math.round(width)), segmentsY: Math.max(1, Math.round(height)) };
434
-
435
- const geometry = new THREE.PlaneGeometry(width, height, terrainDimensions.segmentsX, terrainDimensions.segmentsY);
 
 
 
 
 
 
 
 
436
  geometry.rotateX(-Math.PI / 2);
437
 
438
  const positions = geometry.attributes.position;
439
- if (heightData) {
440
- for (let i = 0; i < positions.count; i++) positions.setY(i, heightData[i]);
 
 
441
  }
442
-
443
- splatMap = new Uint8Array(4 * (terrainDimensions.segmentsX + 1) * (terrainDimensions.segmentsY + 1));
444
- if (splatData) {
445
- splatMap.set(splatData);
 
 
446
  } else {
447
- for (let i = 0; i < splatMap.length; i += 4) {
448
- splatMap[i] = 255; // Default to grass
449
- splatMap[i+1] = 0; splatMap[i+2] = 0; splatMap[i+3] = 0;
 
 
 
450
  }
451
  }
452
- splatMapTexture = new THREE.DataTexture(splatMap, terrainDimensions.segmentsX + 1, terrainDimensions.segmentsY + 1, THREE.RGBAFormat);
453
- splatMapTexture.needsUpdate = true;
454
 
 
 
 
455
  const material = new THREE.ShaderMaterial({
456
- vertexShader,
457
- fragmentShader,
458
  uniforms: {
459
- uSplatMap: { value: splatMapTexture },
460
- uTexture1: { value: terrainTextures[0] },
461
- uTexture2: { value: terrainTextures[1] },
462
- uTexture3: { value: terrainTextures[2] },
463
- uTexture4: { value: terrainTextures[3] },
464
- }
 
 
 
465
  });
466
 
467
  terrainMesh = new THREE.Mesh(geometry, material);
468
  terrainMesh.castShadow = true;
469
  terrainMesh.receiveShadow = true;
470
  scene.add(terrainMesh);
 
471
  geometry.computeVertexNormals();
472
  }
473
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  function onWindowResize() {
475
  camera.aspect = window.innerWidth / window.innerHeight;
476
  camera.updateProjectionMatrix();
477
  renderer.setSize(window.innerWidth, window.innerHeight);
478
  }
 
 
 
 
 
 
 
479
 
480
  function onPointerMove(event) {
481
- const rect = renderer.domElement.getBoundingClientRect();
482
- mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
483
- mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
484
 
485
  raycaster.setFromCamera(mouse, camera);
486
  if (!terrainMesh) return;
@@ -488,19 +509,17 @@ EDITOR_TEMPLATE = '''
488
  const intersects = raycaster.intersectObject(terrainMesh);
489
 
490
  if (intersects.length > 0) {
491
- controls.enabled = !isSculpting;
492
  const intersectionPoint = intersects[0].point;
493
  brushHelper.position.copy(intersectionPoint);
494
- brushHelper.position.y = 0;
495
  brushHelper.visible = true;
496
  updateBrushHelper();
497
 
498
- if (isSculpting) {
499
- applyTool(intersectionPoint);
500
  }
501
  } else {
502
  brushHelper.visible = false;
503
- controls.enabled = true;
504
  }
505
  }
506
 
@@ -510,173 +529,210 @@ EDITOR_TEMPLATE = '''
510
  }
511
 
512
  function onPointerDown(event) {
513
- if (event.button === 0) { isSculpting = true; }
514
- const intersects = raycaster.intersectObject(terrainMesh);
515
- if (intersects.length > 0 && currentTool === 'foliage') {
516
- applyTool(intersects[0].point);
517
- }
518
  }
519
 
520
  function onPointerUp(event) {
521
- if (event.button === 0) { isSculpting = false; controls.enabled = true; }
522
  }
523
 
524
- function applyTool(center) {
525
- switch(currentTool) {
 
 
 
526
  case 'sculpt': sculptTerrain(center); break;
527
- case 'paint': paintTerrain(center); break;
528
- case 'foliage': placeFoliage(center); break;
529
  }
530
  }
531
 
532
  function sculptTerrain(center) {
533
- if (!terrainMesh) return;
534
  const positions = terrainMesh.geometry.attributes.position;
535
  const brushSize = parseFloat(document.getElementById('brush-size').value);
536
  const brushStrength = parseFloat(document.getElementById('brush-strength').value);
537
- const brushMode = document.querySelector('input[name="brush-mode"]:checked').value;
538
- const direction = (brushMode === 'raise') ? 1 : -1;
539
 
540
  const vertex = new THREE.Vector3();
541
  for (let i = 0; i < positions.count; i++) {
542
  vertex.fromBufferAttribute(positions, i);
543
- const distance = Math.sqrt(Math.pow(vertex.x - center.x, 2) + Math.pow(vertex.z - center.z, 2));
544
 
545
  if (distance < brushSize) {
546
- const falloff = Math.pow(1 - (distance / brushSize), 2);
547
- positions.setY(i, positions.getY(i) + direction * falloff * brushStrength);
 
 
 
548
  }
549
  }
550
  positions.needsUpdate = true;
551
  terrainMesh.geometry.computeVertexNormals();
552
  }
553
-
554
- function paintTerrain(center) {
555
- if (!terrainMesh) return;
556
- const brushSize = parseFloat(document.getElementById('brush-size').value);
557
- const positions = terrainMesh.geometry.attributes.position;
558
- const vertex = new THREE.Vector3();
559
- const segmentsX = terrainDimensions.segmentsX;
560
- let needsUpdate = false;
561
-
562
- for (let i = 0; i < positions.count; i++) {
563
- vertex.fromBufferAttribute(positions, i);
564
- const distance = Math.sqrt(Math.pow(vertex.x - center.x, 2) + Math.pow(vertex.z - center.z, 2));
565
 
566
- if (distance < brushSize) {
567
- const index = i * 4;
568
- for (let j=0; j<4; j++) { splatMap[index + j] = (j === selectedTextureId) ? 255 : 0; }
569
- needsUpdate = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  }
571
  }
572
- if(needsUpdate) splatMapTexture.needsUpdate = true;
 
 
 
 
 
 
573
  }
574
 
575
- function placeFoliage(center) {
576
  const brushSize = parseFloat(document.getElementById('brush-size').value);
577
- const density = parseInt(document.getElementById('foliage-density').value);
578
- const scale = parseFloat(document.getElementById('foliage-scale').value);
579
- const asset = foliage[selectedFoliageType];
580
- if (!asset || asset.mesh.count >= MAX_FOLIAGE_INSTANCES) return;
581
-
582
- const dummy = new THREE.Object3D();
583
- const raycasterDown = new THREE.Raycaster();
584
- const down = new THREE.Vector3(0, -1, 0);
585
-
586
- for (let i = 0; i < density; i++) {
587
- if (asset.mesh.count >= MAX_FOLIAGE_INSTANCES) break;
588
-
589
- const angle = Math.random() * 2 * Math.PI;
590
- const radius = Math.random() * brushSize;
591
- const x = center.x + radius * Math.cos(angle);
592
- const z = center.z + radius * Math.sin(angle);
593
-
594
- raycasterDown.set(new THREE.Vector3(x, 50, z), down);
595
- const intersects = raycasterDown.intersectObject(terrainMesh);
596
  if (intersects.length > 0) {
597
- dummy.position.copy(intersects[0].point);
598
- dummy.rotation.y = Math.random() * Math.PI * 2;
599
- const randomScale = scale * (0.8 + Math.random() * 0.4);
600
- dummy.scale.set(randomScale, randomScale, randomScale);
601
- dummy.updateMatrix();
602
- asset.mesh.setMatrixAt(asset.mesh.count++, dummy.matrix);
 
 
603
  }
604
  }
605
- asset.mesh.instanceMatrix.needsUpdate = true;
606
  }
607
-
608
- function clearFoliage() {
609
- for(const type in foliage) {
610
- foliage[type].mesh.count = 0;
611
- foliage[type].mesh.instanceMatrix.needsUpdate = true;
 
 
 
 
 
 
612
  }
 
613
  }
614
 
615
  async function saveProject() {
616
  const projectName = document.getElementById('project-name').value.trim();
617
- if (!projectName || !terrainMesh) { alert("Введите имя проекта и создайте ландшафт."); return; }
 
618
 
619
  showSpinner(true);
620
  const positions = terrainMesh.geometry.attributes.position;
621
- const heightData = Array.from(positions.array).filter((_, i) => i % 3 === 1);
622
 
623
- const foliageData = {};
624
- for(const type in foliage) {
625
- const mesh = foliage[type].mesh;
626
- foliageData[type] = Array.from(mesh.instanceMatrix.array.slice(0, mesh.count * 16));
627
- }
628
 
629
  const projectData = {
630
  name: projectName,
631
  width: terrainDimensions.width,
632
  height: terrainDimensions.height,
633
  heightData: heightData,
634
- splatMapData: Array.from(splatMap),
635
- foliageData: foliageData
636
  };
637
 
638
  try {
639
- const response = await fetch('/api/project', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(projectData) });
640
- if (!response.ok) throw new Error((await response.json()).error || 'Ошибка сохранения.');
 
 
 
 
 
641
  alert(`Проект '${projectName}' успешно сохранен!`);
 
642
  const projectList = document.getElementById('project-list');
643
- if (!Array.from(projectList.options).some(opt => opt.value === projectName)) {
644
- const newOption = new Option(projectName, projectName);
645
- projectList.add(newOption);
 
 
 
646
  }
647
- } catch (error) { alert(`Не удалось сохранить проект: ${error.message}`); } finally { showSpinner(false); }
 
 
 
 
 
 
648
  }
649
 
650
  async function loadProject() {
651
- const projectName = document.getElementById('project-list').value;
652
- if (!projectName) { alert("Выберите проект для загрузки."); return; }
653
-
654
  showSpinner(true);
 
655
  try {
656
  const response = await fetch(`/api/project/${projectName}`);
657
- if (!response.ok) throw new Error((await response.json()).error || 'Ошибка загрузки.');
658
  const result = await response.json();
 
659
 
 
 
 
660
  document.getElementById('project-name').value = projectName;
661
  document.getElementById('terrain-width').value = result.width;
662
  document.getElementById('terrain-height').value = result.height;
663
-
664
- createTerrain(result.width, result.height, result.heightData, result.splatMapData);
665
-
666
- clearFoliage();
667
- if (result.foliageData) {
668
- for(const type in result.foliageData) {
669
- if (foliage[type]) {
670
- const mesh = foliage[type].mesh;
671
- const data = new Float32Array(result.foliageData[type]);
672
- mesh.instanceMatrix.set(data);
673
- mesh.count = data.length / 16;
674
- mesh.instanceMatrix.needsUpdate = true;
675
- }
676
- }
677
- }
678
-
679
- } catch (error) { alert(`Не удалось загрузить проект: ${error.message}`); } finally { showSpinner(false); }
680
  }
681
 
682
  function animate() {
@@ -687,6 +743,7 @@ EDITOR_TEMPLATE = '''
687
 
688
  init();
689
  createTerrain(100, 100);
 
690
  </script>
691
  </body>
692
  </html>
 
97
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
98
  <title>3D Level Designer</title>
99
  <style>
100
+ body { margin: 0; overflow: hidden; background-color: #111; color: white; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
101
  canvas { display: block; }
102
  #ui-panel {
103
  position: absolute;
 
105
  left: 10px;
106
  background: rgba(20, 20, 25, 0.85);
107
  backdrop-filter: blur(10px);
108
+ -webkit-backdrop-filter: blur(10px);
109
  padding: 15px;
110
+ border-radius: 12px;
111
+ border: 1px solid rgba(255, 255, 255, 0.1);
112
+ width: 300px;
113
+ box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
114
  max-height: calc(100vh - 20px);
115
  overflow-y: auto;
116
+ scrollbar-width: thin;
117
+ scrollbar-color: #555 #333;
118
+ }
119
+ .ui-group { margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #333; }
120
+ .ui-group:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
121
+ h3 { margin-top: 0; font-size: 1.1em; color: #00aaff; letter-spacing: 0.5px; }
122
+ label { display: block; margin-bottom: 5px; font-size: 0.9em; color: #ccc; }
123
+ input[type="number"], input[type="text"], select {
124
+ width: 100%; padding: 8px; box-sizing: border-box; background: #2a2a30; border: 1px solid #555; color: white; border-radius: 4px; appearance: none;
125
+ }
126
+ button {
127
+ width: 100%; padding: 10px; background: #0077cc; border: none; color: white; border-radius: 4px; cursor: pointer; font-weight: bold; margin-top: 10px; transition: background-color 0.2s;
128
  }
 
 
 
 
 
129
  button:hover { background: #0099ff; }
 
130
  .slider-container { margin-top: 10px; }
131
+ input[type="range"] { width: 100%; }
132
+ .radio-group { display: flex; flex-wrap: wrap; gap: 10px; }
133
+ .radio-group label { display: flex; align-items: center; cursor: pointer; padding: 5px 10px; background: #333; border-radius: 20px; transition: background-color 0.2s; }
134
+ .radio-group input[type="radio"] { display: none; }
135
+ .radio-group input[type="radio"]:checked + span { color: #00aaff; }
136
+ .radio-group label:hover { background: #444; }
137
+ .texture-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 10px; }
138
+ .texture-grid label { flex-direction: column; align-items: center; text-align: center; background: #2a2a30; border: 2px solid #555; border-radius: 8px; padding: 8px; }
139
+ .texture-grid img { width: 50px; height: 50px; border-radius: 4px; margin-bottom: 5px; image-rendering: pixelated; }
140
+ .texture-grid input[type="radio"]:checked + img + span { color: #00aaff; font-weight: bold; }
141
+ #loading-spinner {
142
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); border: 8px solid #f3f3f3; border-top: 8px solid #3498db; border-radius: 50%; width: 60px; height: 60px; animation: spin 1s linear infinite; display: none; z-index: 100;
143
+ }
144
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
145
  @media (max-width: 768px) {
146
+ #ui-panel { width: calc(100% - 20px); max-height: 45vh; top: auto; bottom: 10px; }
147
+ h3 { font-size: 1em; }
148
+ button { padding: 12px; }
149
  }
150
  </style>
151
  </head>
 
176
  </div>
177
 
178
  <div class="ui-group">
179
+ <h3>Кисть</h3>
180
+ <label>Режим:</label>
181
+ <div class="radio-group" id="brush-main-mode-container">
182
+ <label><input type="radio" name="brush-main-mode" value="sculpt" checked><span>Скульпт</span></label>
183
+ <label><input type="radio" name="brush-main-mode" value="paint"><span>Текстура</span></label>
184
+ <label><input type="radio" name="brush-main-mode" value="foliage"><span>Растительность</span></label>
185
  </div>
186
  </div>
187
+
188
+ <div id="sculpt-options" class="ui-group">
189
+ <label>Скульпт:</label>
190
+ <div class="radio-group" id="brush-sculpt-mode-container">
191
+ <label><input type="radio" name="brush-sculpt-mode" value="raise" checked><span>Поднять</span></label>
192
+ <label><input type="radio" name="brush-sculpt-mode" value="lower"><span>Опустить</span></label>
 
 
 
 
193
  </div>
194
  </div>
195
+
196
+ <div id="paint-options" class="ui-group" style="display: none;">
197
+ <label>Текстура:</label>
198
+ <div class="texture-grid" id="brush-paint-mode-container">
199
+ <label><input type="radio" name="brush-paint-mode" value="0" checked>
200
+ <img src="https://threejs.org/examples/textures/terrain/grasslight-big.jpg" alt="Grass"><span>Трава</span></label>
201
+ <label><input type="radio" name="brush-paint-mode" value="1">
202
+ <img src="https://threejs.org/examples/textures/terrain/sand-512.jpg" alt="Sand"><span>Песок</span></label>
203
+ <label><input type="radio" name="brush-paint-mode" value="2">
204
+ <img src="https://threejs.org/examples/textures/terrain/rock-512.jpg" alt="Rock"><span>Скала</span></label>
205
+ <label><input type="radio" name="brush-paint-mode" value="3">
206
+ <img src="https://threejs.org/examples/textures/terrain/dirt-512.jpg" alt="Dirt"><span>Земля</span></label>
207
  </div>
208
  </div>
209
 
210
+ <div id="foliage-options" class="ui-group" style="display: none;">
211
+ <label>Растительность:</label>
212
+ <div class="radio-group" id="brush-foliage-mode-container">
213
+ <label><input type="radio" name="brush-foliage-mode" value="add" checked><span>Добавить</span></label>
214
+ <label><input type="radio" name="brush-foliage-mode" value="remove"><span>Удалить</span></label>
 
 
 
 
 
 
 
215
  </div>
216
  </div>
217
+
218
  <div class="ui-group">
219
+ <h3>Настройки кисти</h3>
220
  <div class="slider-container">
221
  <label for="brush-size">Размер: <span id="brush-size-value">10</span></label>
222
  <input type="range" id="brush-size" min="1" max="50" value="10">
223
  </div>
224
+ <div class="slider-container">
225
+ <label for="brush-strength">Сила: <span id="brush-strength-value">0.5</span></label>
226
+ <input type="range" id="brush-strength" min="0.1" max="2" step="0.1" value="0.5">
227
+ </div>
228
  </div>
229
  </div>
230
 
231
  <div id="loading-spinner"></div>
232
 
233
  <script type="importmap">
234
+ {
235
+ "imports": {
236
+ "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
237
+ "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
238
+ }
239
+ }
240
  </script>
241
 
242
  <script type="module">
243
  import * as THREE from 'three';
244
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
245
 
246
+ let scene, camera, renderer, controls, terrainMesh, brushHelper, foliageMesh;
247
  let raycaster = new THREE.Raycaster();
248
  let mouse = new THREE.Vector2();
249
+ let isInteracting = false;
250
 
251
+ const terrainDimensions = { width: 100, height: 100, segmentsX: 100, segmentsY: 100 };
252
+ const MAX_FOLIAGE = 50000;
253
+ let foliageInstanceData = [];
254
+
255
+ const textureLoader = new THREE.TextureLoader();
256
+ const textures = {
257
+ grass: textureLoader.load('https://threejs.org/examples/textures/terrain/grasslight-big.jpg'),
258
+ sand: textureLoader.load('https://threejs.org/examples/textures/terrain/sand-512.jpg'),
259
+ rock: textureLoader.load('https://threejs.org/examples/textures/terrain/rock-512.jpg'),
260
+ dirt: textureLoader.load('https://threejs.org/examples/textures/terrain/dirt-512.jpg'),
261
+ };
262
+ for (const key in textures) {
263
+ textures[key].wrapS = textures[key].wrapT = THREE.RepeatWrapping;
264
+ }
265
 
266
  const vertexShader = `
267
  varying vec2 vUv;
 
 
268
  void main() {
269
  vUv = uv;
270
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
 
 
 
271
  }
272
  `;
273
 
274
  const fragmentShader = `
275
+ uniform sampler2D grassTexture;
276
+ uniform sampler2D sandTexture;
277
+ uniform sampler2D rockTexture;
278
+ uniform sampler2D dirtTexture;
279
+ uniform sampler2D splatMap;
280
+ uniform float textureRepeat;
281
  varying vec2 vUv;
 
 
 
282
  void main() {
283
+ vec4 splat = texture2D(splatMap, vUv);
284
+ vec2 repeatedUv = vUv * textureRepeat;
285
+ vec4 grassColor = texture2D(grassTexture, repeatedUv);
286
+ vec4 sandColor = texture2D(sandTexture, repeatedUv);
287
+ vec4 rockColor = texture2D(rockTexture, repeatedUv);
288
+ vec4 dirtColor = texture2D(dirtTexture, repeatedUv);
289
 
290
+ vec4 finalColor = grassColor * splat.r;
291
+ finalColor = mix(finalColor, sandColor, splat.g);
292
+ finalColor = mix(finalColor, rockColor, splat.b);
293
+ finalColor = mix(finalColor, dirtColor, splat.a);
 
 
 
294
 
295
+ gl_FragColor = finalColor;
 
 
 
296
  }
297
  `;
298
+
299
  function init() {
300
  scene = new THREE.Scene();
301
  scene.background = new THREE.Color(0x87ceeb);
302
+ scene.fog = new THREE.Fog(0x87ceeb, 200, 600);
303
 
304
  camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
305
  camera.position.set(50, 50, 50);
 
312
 
313
  controls = new OrbitControls(camera, renderer.domElement);
314
  controls.enableDamping = true;
315
+ controls.dampingFactor = 0.05;
316
 
317
  const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 1.5);
318
  hemiLight.position.set(0, 200, 0);
 
329
  dirLight.shadow.mapSize.height = 2048;
330
  scene.add(dirLight);
331
 
332
+ const brushGeometry = new THREE.CylinderGeometry(1, 1, 200, 32, 1, true);
333
  const brushMaterial = new THREE.MeshBasicMaterial({ color: 0xffff00, wireframe: true, transparent: true, opacity: 0.5 });
334
  brushHelper = new THREE.Mesh(brushGeometry, brushMaterial);
335
  brushHelper.visible = false;
336
  scene.add(brushHelper);
337
+
338
+ setupFoliage();
339
+
 
340
  window.addEventListener('resize', onWindowResize);
341
+ renderer.domElement.addEventListener('pointermove', onPointerMove, false);
342
+ renderer.domElement.addEventListener('pointerdown', onPointerDown, false);
343
+ renderer.domElement.addEventListener('pointerup', onPointerUp, false);
344
+ renderer.domElement.addEventListener('pointerout', () => { brushHelper.visible = false; isInteracting = false; });
345
 
346
  setupUIListeners();
347
  animate();
348
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
 
350
+ function setupFoliage() {
351
+ const grassBlade = new THREE.PlaneGeometry(0.5, 2, 1, 3);
352
+ grassBlade.translate(0, 1, 0);
353
+ const pos = grassBlade.attributes.position;
354
+ for(let i=0; i < pos.count; i++) {
355
+ const y = pos.getY(i);
356
+ pos.setX(i, pos.getX(i) + Math.sin(y * Math.PI) * 0.2);
357
+ }
358
+ grassBlade.computeVertexNormals();
359
 
360
+ const grassMaterial = new THREE.MeshStandardMaterial({
361
+ color: 0x339922,
362
+ side: THREE.DoubleSide,
363
+ roughness: 0.8
364
+ });
365
+ foliageMesh = new THREE.InstancedMesh(grassBlade, grassMaterial, MAX_FOLIAGE);
366
+ foliageMesh.castShadow = true;
367
+ foliageMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
368
+ scene.add(foliageMesh);
369
  }
370
 
371
  function setupUIListeners() {
 
373
  const width = parseInt(document.getElementById('terrain-width').value);
374
  const height = parseInt(document.getElementById('terrain-height').value);
375
  createTerrain(width, height);
 
376
  });
377
 
378
  document.getElementById('save-project').addEventListener('click', saveProject);
379
  document.getElementById('load-project').addEventListener('click', loadProject);
 
380
  document.getElementById('brush-size').addEventListener('input', (e) => {
381
  document.getElementById('brush-size-value').textContent = e.target.value;
382
  updateBrushHelper();
383
  });
384
+ document.getElementById('brush-strength').addEventListener('input', (e) => {
385
+ document.getElementById('brush-strength-value').textContent = e.target.value;
 
 
 
 
 
 
 
 
 
 
 
386
  });
387
+ document.getElementById('project-list').addEventListener('change', (e) => {
388
+ document.getElementById('project-name').value = e.target.value;
 
 
 
 
389
  });
390
 
391
+ document.querySelectorAll('input[name="brush-main-mode"]').forEach(radio => {
392
+ radio.addEventListener('change', (e) => {
393
+ document.getElementById('sculpt-options').style.display = 'none';
394
+ document.getElementById('paint-options').style.display = 'none';
395
+ document.getElementById('foliage-options').style.display = 'none';
396
+ document.getElementById(`${e.target.value}-options`).style.display = 'block';
397
  });
398
  });
399
  }
400
 
401
+ function showSpinner(show) {
402
+ document.getElementById('loading-spinner').style.display = show ? 'block' : 'none';
 
 
403
  }
 
 
404
 
405
+ function createTerrain(width, height, heightData = null, splatMapData = null) {
406
+ if (terrainMesh) {
407
+ scene.remove(terrainMesh);
408
+ terrainMesh.geometry.dispose();
409
+ terrainMesh.material.dispose();
410
+ }
411
 
412
+ terrainDimensions.width = width;
413
+ terrainDimensions.height = height;
414
+ terrainDimensions.segmentsX = Math.max(1, Math.round(width));
415
+ terrainDimensions.segmentsY = Math.max(1, Math.round(height));
416
+
417
+ const geometry = new THREE.PlaneGeometry(
418
+ terrainDimensions.width,
419
+ terrainDimensions.height,
420
+ terrainDimensions.segmentsX,
421
+ terrainDimensions.segmentsY
422
+ );
423
  geometry.rotateX(-Math.PI / 2);
424
 
425
  const positions = geometry.attributes.position;
426
+ if (heightData && heightData.length === positions.count) {
427
+ for (let i = 0; i < positions.count; i++) {
428
+ positions.setY(i, heightData[i]);
429
+ }
430
  }
431
+
432
+ const splatMapWidth = terrainDimensions.segmentsX + 1;
433
+ const splatMapHeight = terrainDimensions.segmentsY + 1;
434
+ let splatData;
435
+ if (splatMapData && splatMapData.length === splatMapWidth * splatMapHeight * 4) {
436
+ splatData = new Uint8Array(splatMapData);
437
  } else {
438
+ splatData = new Uint8Array(splatMapWidth * splatMapHeight * 4);
439
+ for (let i = 0; i < splatData.length; i += 4) {
440
+ splatData[i] = 255; // R channel (grass)
441
+ splatData[i + 1] = 0; // G
442
+ splatData[i + 2] = 0; // B
443
+ splatData[i + 3] = 0; // A
444
  }
445
  }
 
 
446
 
447
+ const splatMap = new THREE.DataTexture(splatData, splatMapWidth, splatMapHeight, THREE.RGBAFormat);
448
+ splatMap.needsUpdate = true;
449
+
450
  const material = new THREE.ShaderMaterial({
 
 
451
  uniforms: {
452
+ grassTexture: { value: textures.grass },
453
+ sandTexture: { value: textures.sand },
454
+ rockTexture: { value: textures.rock },
455
+ dirtTexture: { value: textures.dirt },
456
+ splatMap: { value: splatMap },
457
+ textureRepeat: { value: width / 10 },
458
+ },
459
+ vertexShader: vertexShader,
460
+ fragmentShader: fragmentShader,
461
  });
462
 
463
  terrainMesh = new THREE.Mesh(geometry, material);
464
  terrainMesh.castShadow = true;
465
  terrainMesh.receiveShadow = true;
466
  scene.add(terrainMesh);
467
+
468
  geometry.computeVertexNormals();
469
  }
470
 
471
+ function updateFoliage(data) {
472
+ foliageInstanceData = data || [];
473
+ const dummy = new THREE.Object3D();
474
+ for(let i=0; i < MAX_FOLIAGE; i++) {
475
+ if (i < foliageInstanceData.length) {
476
+ dummy.matrix.fromArray(foliageInstanceData[i]);
477
+ dummy.matrix.decompose(dummy.position, dummy.quaternion, dummy.scale);
478
+ } else {
479
+ dummy.scale.set(0,0,0);
480
+ }
481
+ dummy.updateMatrix();
482
+ foliageMesh.setMatrixAt(i, dummy.matrix);
483
+ }
484
+ foliageMesh.instanceMatrix.needsUpdate = true;
485
+ foliageMesh.count = foliageInstanceData.length;
486
+ }
487
+
488
  function onWindowResize() {
489
  camera.aspect = window.innerWidth / window.innerHeight;
490
  camera.updateProjectionMatrix();
491
  renderer.setSize(window.innerWidth, window.innerHeight);
492
  }
493
+
494
+ function getPointer( event ) {
495
+ if (event.touches) {
496
+ return { x: event.touches[0].clientX, y: event.touches[0].clientY };
497
+ }
498
+ return { x: event.clientX, y: event.clientY };
499
+ }
500
 
501
  function onPointerMove(event) {
502
+ const pointer = getPointer(event);
503
+ mouse.x = (pointer.x / window.innerWidth) * 2 - 1;
504
+ mouse.y = -(pointer.y / window.innerHeight) * 2 + 1;
505
 
506
  raycaster.setFromCamera(mouse, camera);
507
  if (!terrainMesh) return;
 
509
  const intersects = raycaster.intersectObject(terrainMesh);
510
 
511
  if (intersects.length > 0) {
 
512
  const intersectionPoint = intersects[0].point;
513
  brushHelper.position.copy(intersectionPoint);
514
+ brushHelper.position.y = -100;
515
  brushHelper.visible = true;
516
  updateBrushHelper();
517
 
518
+ if (isInteracting) {
519
+ modifyTerrain(intersectionPoint, intersects[0].face.normal, intersects[0].uv);
520
  }
521
  } else {
522
  brushHelper.visible = false;
 
523
  }
524
  }
525
 
 
529
  }
530
 
531
  function onPointerDown(event) {
532
+ event.preventDefault();
533
+ if (event.button === 0 || event.touches) { isInteracting = true; }
534
+ onPointerMove(event);
 
 
535
  }
536
 
537
  function onPointerUp(event) {
538
+ isInteracting = false;
539
  }
540
 
541
+ function modifyTerrain(center, normal, uv) {
542
+ if (!terrainMesh) return;
543
+ const mainMode = document.querySelector('input[name="brush-main-mode"]:checked').value;
544
+
545
+ switch(mainMode) {
546
  case 'sculpt': sculptTerrain(center); break;
547
+ case 'paint': paintTerrain(uv); break;
548
+ case 'foliage': manageFoliage(center, normal); break;
549
  }
550
  }
551
 
552
  function sculptTerrain(center) {
 
553
  const positions = terrainMesh.geometry.attributes.position;
554
  const brushSize = parseFloat(document.getElementById('brush-size').value);
555
  const brushStrength = parseFloat(document.getElementById('brush-strength').value);
556
+ const sculptMode = document.querySelector('input[name="brush-sculpt-mode"]:checked').value;
557
+ const direction = (sculptMode === 'raise') ? 1 : -1;
558
 
559
  const vertex = new THREE.Vector3();
560
  for (let i = 0; i < positions.count; i++) {
561
  vertex.fromBufferAttribute(positions, i);
562
+ const distance = vertex.distanceTo(center);
563
 
564
  if (distance < brushSize) {
565
+ const falloff = (1 - (distance / brushSize));
566
+ const smoothedFalloff = falloff * falloff * (3 - 2 * falloff);
567
+ let currentY = positions.getY(i);
568
+ let newY = currentY + direction * smoothedFalloff * brushStrength;
569
+ positions.setY(i, newY);
570
  }
571
  }
572
  positions.needsUpdate = true;
573
  terrainMesh.geometry.computeVertexNormals();
574
  }
 
 
 
 
 
 
 
 
 
 
 
 
575
 
576
+ function paintTerrain(uv) {
577
+ const splatMap = terrainMesh.material.uniforms.splatMap.value;
578
+ const data = splatMap.image.data;
579
+ const width = splatMap.image.width;
580
+ const height = splatMap.image.height;
581
+ const brushSize = parseFloat(document.getElementById('brush-size').value);
582
+ const brushStrength = parseFloat(document.getElementById('brush-strength').value) * 25;
583
+ const paintMode = parseInt(document.querySelector('input[name="brush-paint-mode"]:checked').value);
584
+
585
+ const radius = (brushSize / terrainDimensions.width) * width;
586
+ const centerX = uv.x * width;
587
+ const centerY = uv.y * height;
588
+
589
+ for (let y = Math.floor(centerY - radius); y <= Math.ceil(centerY + radius); y++) {
590
+ for (let x = Math.floor(centerX - radius); x <= Math.ceil(centerX + radius); x++) {
591
+ if (x < 0 || x >= width || y < 0 || y >= height) continue;
592
+
593
+ const distance = Math.sqrt((x - centerX)**2 + (y - centerY)**2);
594
+ if (distance > radius) continue;
595
+
596
+ const falloff = (1 - (distance / radius));
597
+ const strength = Math.min(255, falloff * brushStrength);
598
+
599
+ const index = (y * width + x) * 4;
600
+
601
+ let currentVal = data[index + paintMode];
602
+ data[index + paintMode] = Math.min(255, currentVal + strength);
603
+
604
+ let total = 0;
605
+ for (let i=0; i<4; i++) total += data[index + i];
606
+ if (total > 0) {
607
+ for (let i=0; i<4; i++) data[index + i] = (data[index + i] / total) * 255;
608
+ }
609
  }
610
  }
611
+ splatMap.needsUpdate = true;
612
+ }
613
+
614
+ function manageFoliage(center, normal) {
615
+ const foliageMode = document.querySelector('input[name="brush-foliage-mode"]:checked').value;
616
+ if (foliageMode === 'add') addFoliage(center, normal);
617
+ else removeFoliage(center);
618
  }
619
 
620
+ function addFoliage(center, normal) {
621
  const brushSize = parseFloat(document.getElementById('brush-size').value);
622
+ const brushStrength = parseFloat(document.getElementById('brush-strength').value);
623
+
624
+ for (let i = 0; i < brushStrength * 2; i++) {
625
+ if (foliageInstanceData.length >= MAX_FOLIAGE) break;
626
+
627
+ const randomAngle = Math.random() * Math.PI * 2;
628
+ const randomRadius = Math.random() * brushSize;
629
+ const position = new THREE.Vector3(
630
+ center.x + Math.cos(randomAngle) * randomRadius,
631
+ center.y,
632
+ center.z + Math.sin(randomAngle) * randomRadius
633
+ );
634
+
635
+ raycaster.set(new THREE.Vector3(position.x, 500, position.z), new THREE.Vector3(0, -1, 0));
636
+ const intersects = raycaster.intersectObject(terrainMesh);
 
 
 
 
637
  if (intersects.length > 0) {
638
+ const obj = new THREE.Object3D();
639
+ obj.position.copy(intersects[0].point);
640
+ obj.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0), intersects[0].face.normal);
641
+ obj.rotateY(Math.random() * Math.PI * 2);
642
+ const scale = 0.8 + Math.random() * 0.4;
643
+ obj.scale.set(scale, scale, scale);
644
+ obj.updateMatrix();
645
+ foliageInstanceData.push(obj.matrix.toArray());
646
  }
647
  }
648
+ updateFoliage(foliageInstanceData);
649
  }
650
+
651
+ function removeFoliage(center) {
652
+ const brushSize = parseFloat(document.getElementById('brush-size').value);
653
+ const dummy = new THREE.Object3D();
654
+ const newFoliageData = [];
655
+ for(let i=0; i < foliageInstanceData.length; i++) {
656
+ dummy.matrix.fromArray(foliageInstanceData[i]);
657
+ dummy.matrix.decompose(dummy.position, dummy.quaternion, dummy.scale);
658
+ if (dummy.position.distanceTo(center) > brushSize) {
659
+ newFoliageData.push(foliageInstanceData[i]);
660
+ }
661
  }
662
+ updateFoliage(newFoliageData);
663
  }
664
 
665
  async function saveProject() {
666
  const projectName = document.getElementById('project-name').value.trim();
667
+ if (!projectName) { alert("Пожалуйста, введите имя проекта."); return; }
668
+ if (!terrainMesh) { alert("Сначала создайте ландшафт для сохранения."); return; }
669
 
670
  showSpinner(true);
671
  const positions = terrainMesh.geometry.attributes.position;
672
+ const heightData = Array.from(positions.array).filter((_, i) => (i % 3) === 1);
673
 
674
+ const splatMap = terrainMesh.material.uniforms.splatMap.value;
675
+ const splatMapData = Array.from(splatMap.image.data);
 
 
 
676
 
677
  const projectData = {
678
  name: projectName,
679
  width: terrainDimensions.width,
680
  height: terrainDimensions.height,
681
  heightData: heightData,
682
+ splatMapData: splatMapData,
683
+ foliageData: foliageInstanceData
684
  };
685
 
686
  try {
687
+ const response = await fetch('/api/project', {
688
+ method: 'POST',
689
+ headers: { 'Content-Type': 'application/json' },
690
+ body: JSON.stringify(projectData)
691
+ });
692
+ const result = await response.json();
693
+ if (!response.ok) { throw new Error(result.error || 'Ошибка при сохранении проекта.'); }
694
  alert(`Проект '${projectName}' успешно сохранен!`);
695
+
696
  const projectList = document.getElementById('project-list');
697
+ const existingOption = Array.from(projectList.options).find(opt => opt.value === projectName);
698
+ if (!existingOption) {
699
+ const newOption = document.createElement('option');
700
+ newOption.value = projectName;
701
+ newOption.textContent = projectName;
702
+ projectList.appendChild(newOption);
703
  }
704
+
705
+ } catch (error) {
706
+ console.error("Ошибка сохранения:", error);
707
+ alert(`Не удалось сохранить проект: ${error.message}`);
708
+ } finally {
709
+ showSpinner(false);
710
+ }
711
  }
712
 
713
  async function loadProject() {
714
+ const projectList = document.getElementById('project-list');
715
+ const projectName = projectList.value;
716
+ if (!projectName) { alert("Пожалуйста, выберите проект для загрузки."); return; }
717
  showSpinner(true);
718
+
719
  try {
720
  const response = await fetch(`/api/project/${projectName}`);
 
721
  const result = await response.json();
722
+ if (!response.ok) { throw new Error(result.error || 'Ошибка при загрузке проекта.'); }
723
 
724
+ createTerrain(result.width, result.height, result.heightData, result.splatMapData);
725
+ updateFoliage(result.foliageData);
726
+
727
  document.getElementById('project-name').value = projectName;
728
  document.getElementById('terrain-width').value = result.width;
729
  document.getElementById('terrain-height').value = result.height;
730
+ } catch (error) {
731
+ console.error("Ошибка загрузки:", error);
732
+ alert(`Не удалось загрузить проект: ${error.message}`);
733
+ } finally {
734
+ showSpinner(false);
735
+ }
 
 
 
 
 
 
 
 
 
 
 
736
  }
737
 
738
  function animate() {
 
743
 
744
  init();
745
  createTerrain(100, 100);
746
+ updateFoliage([]);
747
  </script>
748
  </body>
749
  </html>