Kgshop commited on
Commit
7a5cc8e
·
verified ·
1 Parent(s): 2a907a5

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +247 -498
app.py CHANGED
@@ -1,4 +1,3 @@
1
-
2
  import os
3
  import json
4
  import time
@@ -25,7 +24,6 @@ DOWNLOAD_DELAY = 5
25
 
26
  def upload_project_to_hf(local_path, project_name):
27
  if not HF_TOKEN_WRITE:
28
- print("HF_TOKEN_WRITE is not set. Cannot upload.")
29
  return False
30
  try:
31
  api = HfApi()
@@ -39,13 +37,11 @@ def upload_project_to_hf(local_path, project_name):
39
  )
40
  return True
41
  except Exception as e:
42
- print(f"Error uploading {project_name} to HF: {e}")
43
  return False
44
 
45
  def download_project_from_hf(project_name, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
46
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
47
  if not token_to_use:
48
- print("No Hugging Face token found for reading.")
49
  return None
50
 
51
  for attempt in range(retries + 1):
@@ -65,10 +61,8 @@ def download_project_from_hf(project_name, retries=DOWNLOAD_RETRIES, delay=DOWNL
65
  os.remove(local_path)
66
  return data
67
  except (HfHubHTTPError, RepositoryNotFoundError) as e:
68
- print(f"Project '{project_name}' not found on HF Hub on attempt {attempt + 1}: {e}")
69
  return None
70
  except Exception as e:
71
- print(f"An unexpected error occurred during download attempt {attempt + 1}: {e}")
72
  if attempt < retries:
73
  time.sleep(delay)
74
  return None
@@ -84,10 +78,8 @@ def list_projects_from_hf():
84
  project_files = [f.split('/')[-1].replace('.json', '') for f in repo_info if f.startswith('projects/') and f.endswith('.json')]
85
  return sorted(project_files)
86
  except RepositoryNotFoundError:
87
- print("Repository not found, creating 'projects' directory might be needed on first save.")
88
  return []
89
  except Exception as e:
90
- print(f"Error listing projects from HF: {e}")
91
  return []
92
 
93
  EDITOR_TEMPLATE = '''
@@ -95,80 +87,48 @@ EDITOR_TEMPLATE = '''
95
  <html lang="en">
96
  <head>
97
  <meta charset="UTF-8">
98
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
99
  <title>3D Level Designer</title>
100
  <style>
101
- * {
102
- box-sizing: border-box;
103
- touch-action: manipulation;
104
- }
105
-
106
- body {
107
- margin: 0;
108
- overflow: hidden;
109
- background-color: #111;
110
- color: white;
111
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
112
- }
113
-
114
- canvas {
115
- display: block;
116
- touch-action: none;
117
- }
118
-
119
  #ui-panel {
120
  position: absolute;
121
  top: 10px;
122
  left: 10px;
123
- background: rgba(0, 0, 0, 0.85);
124
  padding: 15px;
125
  border-radius: 8px;
126
  border: 1px solid #444;
127
  max-width: 300px;
128
- z-index: 10;
129
- backdrop-filter: blur(5px);
130
- max-height: calc(100vh - 40px);
131
- overflow-y: auto;
132
- }
133
-
134
- .ui-group {
135
- margin-bottom: 15px;
136
- padding-bottom: 15px;
137
- border-bottom: 1px solid #333;
138
  }
139
-
140
- .ui-group:last-child {
141
- border-bottom: none;
142
- margin-bottom: 0;
143
- padding-bottom: 0;
144
- }
145
-
146
- h3 {
147
- margin-top: 0;
148
- font-size: 1.1em;
149
- color: #00aaff;
150
- }
151
-
152
- label {
153
- display: block;
154
- margin-bottom: 5px;
155
- font-size: 0.9em;
156
  }
157
-
158
- input[type="number"], input[type="text"], select, input[type="range"] {
 
 
 
159
  width: 100%;
160
- padding: 10px;
161
  box-sizing: border-box;
162
  background: #222;
163
  border: 1px solid #555;
164
  color: white;
165
  border-radius: 4px;
166
- font-size: 16px;
167
  }
168
-
169
  button {
170
  width: 100%;
171
- padding: 12px;
172
  background: #0077cc;
173
  border: none;
174
  color: white;
@@ -176,23 +136,11 @@ EDITOR_TEMPLATE = '''
176
  cursor: pointer;
177
  font-weight: bold;
178
  margin-top: 10px;
179
- font-size: 16px;
180
- }
181
-
182
- button:hover {
183
- background: #0099ff;
184
- }
185
-
186
- .slider-container {
187
- margin-top: 10px;
188
  }
189
-
190
- #brush-mode-container label {
191
- display: inline-block;
192
- margin-right: 15px;
193
- cursor: pointer;
194
- }
195
-
196
  #loading-spinner {
197
  position: absolute;
198
  top: 50%;
@@ -207,95 +155,19 @@ EDITOR_TEMPLATE = '''
207
  display: none;
208
  z-index: 100;
209
  }
210
-
211
- @keyframes spin {
212
- 0% { transform: rotate(0deg); }
213
- 100% { transform: rotate(360deg); }
214
- }
215
-
216
- .material-selector {
217
- display: flex;
218
- flex-wrap: wrap;
219
- gap: 5px;
220
- }
221
-
222
- .material-option {
223
- width: 40px;
224
- height: 40px;
225
- border: 2px solid #555;
226
- border-radius: 4px;
227
- cursor: pointer;
228
- }
229
-
230
- .material-option.selected {
231
- border-color: #00aaff;
232
- box-shadow: 0 0 5px #00aaff;
233
- }
234
-
235
- .grass-toggle {
236
- display: flex;
237
- align-items: center;
238
- gap: 10px;
239
- }
240
-
241
- .grass-toggle input {
242
- width: auto;
243
- }
244
-
245
- .mobile-controls {
246
- position: absolute;
247
- bottom: 20px;
248
- left: 50%;
249
- transform: translateX(-50%);
250
- display: none;
251
- gap: 15px;
252
- z-index: 10;
253
- }
254
-
255
- .mobile-btn {
256
- width: 60px;
257
- height: 60px;
258
- border-radius: 50%;
259
- background: rgba(0, 119, 204, 0.7);
260
- border: 2px solid white;
261
- color: white;
262
- font-size: 24px;
263
- display: flex;
264
- align-items: center;
265
- justify-content: center;
266
- cursor: pointer;
267
- user-select: none;
268
- }
269
-
270
- @media (max-width: 768px) {
271
- #ui-panel {
272
- max-width: calc(100vw - 20px);
273
- left: 10px;
274
- right: 10px;
275
- top: 10px;
276
- max-height: 40vh;
277
- }
278
-
279
- .mobile-controls {
280
- display: flex;
281
- }
282
- }
283
-
284
- @media (max-width: 480px) {
285
- #ui-panel {
286
- padding: 10px;
287
- }
288
-
289
- .mobile-btn {
290
- width: 50px;
291
- height: 50px;
292
- font-size: 20px;
293
- }
294
- }
295
  </style>
296
  </head>
297
  <body>
298
  <div id="ui-panel">
 
 
 
 
 
 
 
 
299
  <div class="ui-group">
300
  <h3>Ландшафт</h3>
301
  <label for="terrain-width">Ширина:</label>
@@ -320,12 +192,19 @@ EDITOR_TEMPLATE = '''
320
  <button id="save-project">Сохранить проект</button>
321
  </div>
322
 
323
- <div class="ui-group">
324
  <h3>Кисть</h3>
325
  <div id="brush-mode-container">
326
  <label><input type="radio" name="brush-mode" value="raise" checked> Поднять</label>
327
  <label><input type="radio" name="brush-mode" value="lower"> Опустить</label>
328
- <label><input type="radio" name="brush-mode" value="smooth"> Сгладить</label>
 
 
 
 
 
 
 
329
  </div>
330
  <div class="slider-container">
331
  <label for="brush-size">Размер кисти: <span id="brush-size-value">10</span></label>
@@ -338,41 +217,10 @@ EDITOR_TEMPLATE = '''
338
  </div>
339
 
340
  <div class="ui-group">
341
- <h3>Материалы</h3>
342
- <div class="material-selector">
343
- <div class="material-option selected" style="background-color: #55aa33;" data-material="grass"></div>
344
- <div class="material-option" style="background-color: #8B4513;" data-material="dirt"></div>
345
- <div class="material-option" style="background-color: #808080;" data-material="rock"></div>
346
- <div class="material-option" style="background-color: #228B22;" data-material="forest"></div>
347
- <div class="material-option" style="background-color: #A52A2A;" data-material="sand"></div>
348
- </div>
349
- <div class="slider-container">
350
- <label for="material-brush-size">Размер кисти материалов: <span id="material-brush-size-value">20</span></label>
351
- <input type="range" id="material-brush-size" min="5" max="100" value="20">
352
- </div>
353
  </div>
354
-
355
- <div class="ui-group">
356
- <h3>Детализация</h3>
357
- <div class="grass-toggle">
358
- <label for="toggle-grass">Трава:</label>
359
- <input type="checkbox" id="toggle-grass">
360
- </div>
361
- <div class="slider-container">
362
- <label for="grass-density">Плотность травы: <span id="grass-density-value">0.3</span></label>
363
- <input type="range" id="grass-density" min="0.1" max="1" step="0.1" value="0.3">
364
- </div>
365
- <div class="slider-container">
366
- <label for="tree-density">Плотность деревьев: <span id="tree-density-value">0.1</span></label>
367
- <input type="range" id="tree-density" min="0" max="0.5" step="0.05" value="0.1">
368
- </div>
369
- </div>
370
- </div>
371
-
372
- <div class="mobile-controls">
373
- <div class="mobile-btn" id="mobile-raise">+</div>
374
- <div class="mobile-btn" id="mobile-lower">-</div>
375
- <div class="mobile-btn" id="mobile-smooth">~</div>
376
  </div>
377
 
378
  <div id="loading-spinner"></div>
@@ -380,8 +228,8 @@ EDITOR_TEMPLATE = '''
380
  <script type="importmap">
381
  {
382
  "imports": {
383
- "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
384
- "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
385
  }
386
  }
387
  </script>
@@ -389,26 +237,14 @@ EDITOR_TEMPLATE = '''
389
  <script type="module">
390
  import * as THREE from 'three';
391
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
392
- import { ImprovedNoise } from 'three/addons/math/ImprovedNoise.js';
393
 
394
- let scene, camera, renderer, controls, terrainMesh, brushHelper, grassInstances, treeInstances;
395
  let raycaster = new THREE.Raycaster();
396
  let mouse = new THREE.Vector2();
397
- let isSculpting = false;
398
- let currentBrushMode = 'raise';
399
- let currentMaterial = 'grass';
400
- let grassEnabled = true;
401
- let noise = new ImprovedNoise();
402
-
403
  const terrainDimensions = { width: 100, height: 100, segmentsX: 100, segmentsY: 100 };
404
- const materialData = []; // Хранит тип материала для каждой вершины
405
- const materials = {
406
- grass: { color: 0x55aa33, roughness: 0.8, metalness: 0.2 },
407
- dirt: { color: 0x8B4513, roughness: 0.9, metalness: 0.1 },
408
- rock: { color: 0x808080, roughness: 0.7, metalness: 0.3 },
409
- forest: { color: 0x228B22, roughness: 0.85, metalness: 0.15 },
410
- sand: { color: 0xA52A2A, roughness: 0.95, metalness: 0.05 }
411
- };
412
 
413
  function init() {
414
  scene = new THREE.Scene();
@@ -461,11 +297,19 @@ EDITOR_TEMPLATE = '''
461
  renderer.domElement.addEventListener('pointerout', () => { brushHelper.visible = false; });
462
 
463
  setupUIListeners();
464
- setupMobileControls();
465
  animate();
466
  }
467
 
468
  function setupUIListeners() {
 
 
 
 
 
 
 
 
 
469
  document.getElementById('create-terrain').addEventListener('click', () => {
470
  const width = parseInt(document.getElementById('terrain-width').value);
471
  const height = parseInt(document.getElementById('terrain-height').value);
@@ -474,70 +318,25 @@ EDITOR_TEMPLATE = '''
474
 
475
  document.getElementById('save-project').addEventListener('click', saveProject);
476
  document.getElementById('load-project').addEventListener('click', loadProject);
477
-
478
  document.getElementById('brush-size').addEventListener('input', (e) => {
479
  document.getElementById('brush-size-value').textContent = e.target.value;
480
  updateBrushHelper();
481
  });
482
-
483
  document.getElementById('brush-strength').addEventListener('input', (e) => {
484
  document.getElementById('brush-strength-value').textContent = e.target.value;
485
  });
486
-
487
- document.getElementById('material-brush-size').addEventListener('input', (e) => {
488
- document.getElementById('material-brush-size-value').textContent = e.target.value;
489
- });
490
-
491
- document.getElementById('grass-density').addEventListener('input', (e) => {
492
- document.getElementById('grass-density-value').textContent = e.target.value;
493
- updateGrass();
494
- });
495
-
496
- document.getElementById('tree-density').addEventListener('input', (e) => {
497
- document.getElementById('tree-density-value').textContent = e.target.value;
498
- updateTrees();
499
- });
500
-
501
- document.getElementById('toggle-grass').addEventListener('change', (e) => {
502
- grassEnabled = e.target.checked;
503
- if (grassInstances) {
504
- grassInstances.visible = grassEnabled;
505
- }
506
- });
507
-
508
  document.getElementById('project-list').addEventListener('change', (e) => {
509
  document.getElementById('project-name').value = e.target.value;
510
  });
511
-
512
- document.querySelectorAll('input[name="brush-mode"]').forEach(radio => {
513
- radio.addEventListener('change', (e) => {
514
- currentBrushMode = e.target.value;
515
- });
516
  });
517
-
518
- document.querySelectorAll('.material-option').forEach(option => {
519
- option.addEventListener('click', (e) => {
520
- document.querySelectorAll('.material-option').forEach(o => o.classList.remove('selected'));
521
- e.target.classList.add('selected');
522
- currentMaterial = e.target.dataset.material;
523
- });
524
- });
525
- }
526
-
527
- function setupMobileControls() {
528
- document.getElementById('mobile-raise').addEventListener('click', () => {
529
- document.querySelector('input[value="raise"]').checked = true;
530
- currentBrushMode = 'raise';
531
- });
532
-
533
- document.getElementById('mobile-lower').addEventListener('click', () => {
534
- document.querySelector('input[value="lower"]').checked = true;
535
- currentBrushMode = 'lower';
536
- });
537
-
538
- document.getElementById('mobile-smooth').addEventListener('click', () => {
539
- document.querySelector('input[value="smooth"]').checked = true;
540
- currentBrushMode = 'smooth';
541
  });
542
  }
543
 
@@ -545,7 +344,7 @@ EDITOR_TEMPLATE = '''
545
  document.getElementById('loading-spinner').style.display = show ? 'block' : 'none';
546
  }
547
 
548
- function createTerrain(width, height, data = null, materialDataArray = null) {
549
  if (terrainMesh) {
550
  scene.remove(terrainMesh);
551
  terrainMesh.geometry.dispose();
@@ -570,47 +369,52 @@ EDITOR_TEMPLATE = '''
570
  for (let i = 0; i < positions.count; i++) {
571
  positions.setY(i, data[i]);
572
  }
573
- } else {
574
- // Генерация случайного ландшафта
575
- const positions = geometry.attributes.position;
576
- for (let i = 0; i < positions.count; i++) {
577
- const x = positions.getX(i);
578
- const z = positions.getZ(i);
579
- const y = noise.noise(x * 0.02, z * 0.02, 0) * 10 +
580
- noise.noise(x * 0.05, z * 0.05, 0) * 5 +
581
- noise.noise(x * 0.1, z * 0.1, 0) * 2;
582
- positions.setY(i, y);
583
- }
584
- }
585
-
586
- // Инициализация данных материалов
587
- if (materialDataArray) {
588
- materialData.length = 0;
589
- materialData.push(...materialDataArray);
590
- } else {
591
- materialData.length = 0;
592
- for (let i = 0; i < geometry.attributes.position.count; i++) {
593
- materialData.push('grass');
594
- }
595
  }
596
 
597
  const material = new THREE.MeshStandardMaterial({
598
- color: 0x55aa33,
599
- flatShading: false,
600
  roughness: 0.8,
601
  metalness: 0.2
602
  });
603
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
  terrainMesh = new THREE.Mesh(geometry, material);
605
  terrainMesh.castShadow = true;
606
  terrainMesh.receiveShadow = true;
607
  scene.add(terrainMesh);
608
 
609
  geometry.computeVertexNormals();
610
-
611
- // Создание травы и деревьев
612
- createGrass();
613
- createTrees();
 
 
 
 
 
 
 
 
 
614
  }
615
 
616
  function onWindowResize() {
@@ -635,12 +439,8 @@ EDITOR_TEMPLATE = '''
635
  brushHelper.visible = true;
636
  updateBrushHelper();
637
 
638
- if (isSculpting) {
639
- if (event.shiftKey) {
640
- paintMaterial(intersectionPoint);
641
- } else {
642
- sculptTerrain(intersectionPoint);
643
- }
644
  }
645
  } else {
646
  brushHelper.visible = false;
@@ -653,218 +453,161 @@ EDITOR_TEMPLATE = '''
653
  }
654
 
655
  function onPointerDown(event) {
656
- if (event.button === 0) {
657
- isSculpting = true;
658
  }
659
  }
660
 
661
  function onPointerUp(event) {
662
  if (event.button === 0) {
663
- isSculpting = false;
664
  }
665
  }
666
 
667
- function sculptTerrain(center) {
668
  if (!terrainMesh) return;
669
- const positions = terrainMesh.geometry.attributes.position;
670
- const brushSize = parseFloat(document.getElementById('brush-size').value);
671
- const brushStrength = parseFloat(document.getElementById('brush-strength').value);
672
- const direction = (currentBrushMode === 'raise') ? 1 : (currentBrushMode === 'lower') ? -1 : 0;
673
 
674
- const vertex = new THREE.Vector3();
675
- for (let i = 0; i < positions.count; i++) {
676
- vertex.fromBufferAttribute(positions, i);
677
- const distance = vertex.distanceTo(center);
678
-
679
- if (distance < brushSize) {
680
- const falloff = (1 - (distance / brushSize));
681
- const smoothedFalloff = falloff * falloff * (3 - 2 * falloff);
682
-
683
- let currentY = positions.getY(i);
684
- if (currentBrushMode === 'smooth') {
685
- // Простое сглаживание с соседями
686
- let sum = currentY;
687
- let count = 1;
688
- const x = i % (terrainDimensions.segmentsX + 1);
689
- const y = Math.floor(i / (terrainDimensions.segmentsX + 1));
690
 
691
- // Проверяем соседей
692
- for (let dx = -1; dx <= 1; dx++) {
693
- for (let dy = -1; dy <= 1; dy++) {
694
- const nx = x + dx;
695
- const ny = y + dy;
696
- if (nx >= 0 && nx <= terrainDimensions.segmentsX &&
697
- ny >= 0 && ny <= terrainDimensions.segmentsY) {
698
- const ni = ny * (terrainDimensions.segmentsX + 1) + nx;
699
- if (ni !== i) {
700
- sum += positions.getY(ni);
701
- count++;
702
- }
703
- }
704
- }
705
- }
706
- const avg = sum / count;
707
- let newY = currentY + (avg - currentY) * smoothedFalloff * brushStrength;
708
- positions.setY(i, newY);
709
- } else {
710
  let newY = currentY + direction * smoothedFalloff * brushStrength;
711
  positions.setY(i, newY);
712
  }
713
  }
714
- }
715
- positions.needsUpdate = true;
716
- terrainMesh.geometry.computeVertexNormals();
717
- updateGrass();
718
- updateTrees();
719
- }
720
-
721
- function paintMaterial(center) {
722
- if (!terrainMesh) return;
723
- const positions = terrainMesh.geometry.attributes.position;
724
- const materialBrushSize = parseFloat(document.getElementById('material-brush-size').value);
725
-
726
- const vertex = new THREE.Vector3();
727
- for (let i = 0; i < positions.count; i++) {
728
- vertex.fromBufferAttribute(positions, i);
729
- const distance = vertex.distanceTo(center);
730
-
731
- if (distance < materialBrushSize) {
732
- materialData[i] = currentMaterial;
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  }
 
734
  }
735
-
736
- // Обновление цвета ландшафта
737
- updateTerrainMaterial();
738
- }
739
-
740
- function updateTerrainMaterial() {
741
- if (!terrainMesh) return;
742
- const geometry = terrainMesh.geometry;
743
- const positions = geometry.attributes.position;
744
- const colors = [];
745
-
746
- for (let i = 0; i < positions.count; i++) {
747
- const materialType = materialData[i];
748
- const materialProps = materials[materialType];
749
- const color = new THREE.Color(materialProps.color);
750
- colors.push(color.r, color.g, color.b);
751
- }
752
-
753
- geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
754
- terrainMesh.material.vertexColors = true;
755
- terrainMesh.material.needsUpdate = true;
756
  }
757
 
758
- function createGrass() {
759
- if (grassInstances) {
760
- scene.remove(grassInstances);
761
- grassInstances.geometry.dispose();
762
- grassInstances.material.dispose();
763
- }
764
-
765
- const grassGeometry = new THREE.ConeGeometry(0.3, 1, 4);
766
- const grassMaterial = new THREE.MeshBasicMaterial({ color: 0x33aa33 });
767
-
768
- const count = Math.floor(terrainDimensions.width * terrainDimensions.height *
769
- parseFloat(document.getElementById('grass-density').value));
770
-
771
- grassInstances = new THREE.InstancedMesh(grassGeometry, grassMaterial, count);
772
- grassInstances.castShadow = true;
773
- grassInstances.receiveShadow = true;
774
- grassInstances.visible = grassEnabled;
775
-
776
- const dummy = new THREE.Object3D();
777
  const positions = terrainMesh.geometry.attributes.position;
778
-
779
- for (let i = 0; i < count; i++) {
780
- const index = Math.floor(Math.random() * positions.count);
781
- const x = positions.getX(index);
782
- const y = positions.getY(index);
783
- const z = positions.getZ(index);
784
-
785
- dummy.position.set(x, y + 0.5, z);
786
- dummy.rotation.y = Math.random() * Math.PI * 2;
787
- dummy.scale.set(1, 0.5 + Math.random(), 1);
788
- dummy.updateMatrix();
789
- grassInstances.setMatrixAt(i, dummy.matrix);
790
- }
791
-
792
- scene.add(grassInstances);
793
- }
794
-
795
- function updateGrass() {
796
- if (grassInstances) {
797
- scene.remove(grassInstances);
798
- grassInstances.geometry.dispose();
799
- grassInstances.material.dispose();
800
- }
801
- if (terrainMesh) {
802
- createGrass();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
803
  }
 
 
 
804
  }
805
-
806
- function createTrees() {
807
- if (treeInstances) {
808
- scene.remove(treeInstances);
809
- treeInstances.geometry.dispose();
810
- treeInstances.material.dispose();
811
- }
812
 
813
- // Создание простой геометрии дерева
814
- const treeGroup = new THREE.Group();
815
-
816
- // Ствол
817
- const trunkGeometry = new THREE.CylinderGeometry(0.2, 0.3, 2, 8);
818
- const trunkMaterial = new THREE.MeshStandardMaterial({ color: 0x8B4513 });
819
- const trunk = new THREE.Mesh(trunkGeometry, trunkMaterial);
820
- trunk.position.y = 1;
821
- trunk.castShadow = true;
822
- trunk.receiveShadow = true;
823
- treeGroup.add(trunk);
824
-
825
- // Крона
826
- const crownGeometry = new THREE.ConeGeometry(1.5, 3, 8);
827
- const crownMaterial = new THREE.MeshStandardMaterial({ color: 0x228B22 });
828
- const crown = new THREE.Mesh(crownGeometry, crownMaterial);
829
- crown.position.y = 3.5;
830
- crown.castShadow = true;
831
- crown.receiveShadow = true;
832
- treeGroup.add(crown);
833
-
834
- const density = parseFloat(document.getElementById('tree-density').value);
835
- const count = Math.floor(terrainDimensions.width * terrainDimensions.height * density);
836
-
837
- treeInstances = new THREE.InstancedMesh(treeGroup, new THREE.MeshBasicMaterial(), count);
838
- treeInstances.castShadow = true;
839
- treeInstances.receiveShadow = true;
840
-
841
- const dummy = new THREE.Object3D();
842
- const positions = terrainMesh.geometry.attributes.position;
843
-
844
- for (let i = 0; i < count; i++) {
845
- const index = Math.floor(Math.random() * positions.count);
846
- const x = positions.getX(index);
847
- const y = positions.getY(index);
848
- const z = positions.getZ(index);
849
-
850
- dummy.position.set(x, y, z);
851
- dummy.rotation.y = Math.random() * Math.PI * 2;
852
- dummy.scale.set(0.5 + Math.random() * 0.5, 0.8 + Math.random() * 0.4, 0.5 + Math.random() * 0.5);
853
- dummy.updateMatrix();
854
- treeInstances.setMatrixAt(i, dummy.matrix);
855
  }
856
-
857
- scene.add(treeInstances);
858
  }
859
-
860
- function updateTrees() {
861
- if (treeInstances) {
862
- scene.remove(treeInstances);
863
- treeInstances.geometry.dispose();
864
- treeInstances.material.dispose();
865
- }
866
- if (terrainMesh) {
867
- createTrees();
 
 
 
 
868
  }
869
  }
870
 
@@ -891,7 +634,9 @@ EDITOR_TEMPLATE = '''
891
  width: terrainDimensions.width,
892
  height: terrainDimensions.height,
893
  data: heightData,
894
- materialData: materialData
 
 
895
  };
896
 
897
  try {
@@ -916,7 +661,6 @@ EDITOR_TEMPLATE = '''
916
  }
917
 
918
  } catch (error) {
919
- console.error("Ошибка сохранения:", error);
920
  alert(`Не удалось сохранить проект: ${error.message}`);
921
  } finally {
922
  showSpinner(false);
@@ -939,12 +683,17 @@ EDITOR_TEMPLATE = '''
939
  throw new Error(result.error || 'Ошибка при загрузке проекта.');
940
  }
941
 
942
- createTerrain(result.width, result.height, result.data, result.materialData);
943
  document.getElementById('project-name').value = projectName;
944
  document.getElementById('terrain-width').value = result.width;
945
  document.getElementById('terrain-height').value = result.height;
 
 
 
 
 
 
946
  } catch (error) {
947
- console.error("Ошибка загрузки:", error);
948
  alert(`Не удалось загрузить проект: ${error.message}`);
949
  } finally {
950
  showSpinner(false);
@@ -1000,4 +749,4 @@ def load_project_api(project_name):
1000
 
1001
  if __name__ == '__main__':
1002
  port = int(os.environ.get('PORT', 7860))
1003
- app.run(debug=False, host='0.0.0.0', port=port)
 
 
1
  import os
2
  import json
3
  import time
 
24
 
25
  def upload_project_to_hf(local_path, project_name):
26
  if not HF_TOKEN_WRITE:
 
27
  return False
28
  try:
29
  api = HfApi()
 
37
  )
38
  return True
39
  except Exception as e:
 
40
  return False
41
 
42
  def download_project_from_hf(project_name, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
43
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
44
  if not token_to_use:
 
45
  return None
46
 
47
  for attempt in range(retries + 1):
 
61
  os.remove(local_path)
62
  return data
63
  except (HfHubHTTPError, RepositoryNotFoundError) as e:
 
64
  return None
65
  except Exception as e:
 
66
  if attempt < retries:
67
  time.sleep(delay)
68
  return None
 
78
  project_files = [f.split('/')[-1].replace('.json', '') for f in repo_info if f.startswith('projects/') and f.endswith('.json')]
79
  return sorted(project_files)
80
  except RepositoryNotFoundError:
 
81
  return []
82
  except Exception as e:
 
83
  return []
84
 
85
  EDITOR_TEMPLATE = '''
 
87
  <html lang="en">
88
  <head>
89
  <meta charset="UTF-8">
90
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
91
  <title>3D Level Designer</title>
92
  <style>
93
+ body { margin: 0; overflow: hidden; background-color: #111; color: white; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
94
+ canvas { display: block; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  #ui-panel {
96
  position: absolute;
97
  top: 10px;
98
  left: 10px;
99
+ background: rgba(0, 0, 0, 0.7);
100
  padding: 15px;
101
  border-radius: 8px;
102
  border: 1px solid #444;
103
  max-width: 300px;
 
 
 
 
 
 
 
 
 
 
104
  }
105
+ @media (max-width: 768px) {
106
+ #ui-panel {
107
+ max-width: none;
108
+ width: calc(100% - 20px);
109
+ left: 10px;
110
+ top: auto;
111
+ bottom: 10px;
112
+ overflow-y: auto;
113
+ max-height: 40vh;
114
+ }
 
 
 
 
 
 
 
115
  }
116
+ .ui-group { margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #333; }
117
+ .ui-group:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
118
+ h3 { margin-top: 0; font-size: 1.1em; color: #00aaff; }
119
+ label { display: block; margin-bottom: 5px; font-size: 0.9em; }
120
+ input[type="number"], input[type="text"], select {
121
  width: 100%;
122
+ padding: 8px;
123
  box-sizing: border-box;
124
  background: #222;
125
  border: 1px solid #555;
126
  color: white;
127
  border-radius: 4px;
 
128
  }
 
129
  button {
130
  width: 100%;
131
+ padding: 10px;
132
  background: #0077cc;
133
  border: none;
134
  color: white;
 
136
  cursor: pointer;
137
  font-weight: bold;
138
  margin-top: 10px;
 
 
 
 
 
 
 
 
 
139
  }
140
+ button:hover { background: #0099ff; }
141
+ .slider-container { margin-top: 10px; }
142
+ input[type="range"] { width: 100%; }
143
+ #brush-mode-container label, #mode-container label { display: inline-block; margin-right: 10px; }
 
 
 
144
  #loading-spinner {
145
  position: absolute;
146
  top: 50%;
 
155
  display: none;
156
  z-index: 100;
157
  }
158
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </style>
160
  </head>
161
  <body>
162
  <div id="ui-panel">
163
+ <div class="ui-group">
164
+ <h3>Режим</h3>
165
+ <div id="mode-container">
166
+ <label><input type="radio" name="mode" value="camera" checked> Камера</label>
167
+ <label><input type="radio" name="mode" value="sculpt"> Скульптинг</label>
168
+ <label><input type="radio" name="mode" value="paint"> Рисование</label>
169
+ </div>
170
+ </div>
171
  <div class="ui-group">
172
  <h3>Ландшафт</h3>
173
  <label for="terrain-width">Ширина:</label>
 
192
  <button id="save-project">Сохранить проект</button>
193
  </div>
194
 
195
+ <div class="ui-group" id="brush-group">
196
  <h3>Кисть</h3>
197
  <div id="brush-mode-container">
198
  <label><input type="radio" name="brush-mode" value="raise" checked> Поднять</label>
199
  <label><input type="radio" name="brush-mode" value="lower"> Опустить</label>
200
+ </div>
201
+ <div id="paint-options" style="display:none;">
202
+ <label for="paint-texture">Текстура:</label>
203
+ <select id="paint-texture">
204
+ <option value="0">Трава</option>
205
+ <option value="1">Грунт</option>
206
+ <option value="2">Скала</option>
207
+ </select>
208
  </div>
209
  <div class="slider-container">
210
  <label for="brush-size">Размер кисти: <span id="brush-size-value">10</span></label>
 
217
  </div>
218
 
219
  <div class="ui-group">
220
+ <h3>Элементы</h3>
221
+ <label><input type="checkbox" id="show-water"> Показать воду</label>
222
+ <label><input type="checkbox" id="show-grass"> Показать траву</label>
 
 
 
 
 
 
 
 
 
223
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  </div>
225
 
226
  <div id="loading-spinner"></div>
 
228
  <script type="importmap">
229
  {
230
  "imports": {
231
+ "three": "https://unpkg.com/three@0.160.0/build/three.module.js ",
232
+ "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/ "
233
  }
234
  }
235
  </script>
 
237
  <script type="module">
238
  import * as THREE from 'three';
239
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
 
240
 
241
+ let scene, camera, renderer, controls, terrainMesh, brushHelper, splatTexture, grassGroup, waterMesh;
242
  let raycaster = new THREE.Raycaster();
243
  let mouse = new THREE.Vector2();
244
+ let isModifying = false;
245
+ let mode = 'camera';
246
+ const splatRes = 128;
 
 
 
247
  const terrainDimensions = { width: 100, height: 100, segmentsX: 100, segmentsY: 100 };
 
 
 
 
 
 
 
 
248
 
249
  function init() {
250
  scene = new THREE.Scene();
 
297
  renderer.domElement.addEventListener('pointerout', () => { brushHelper.visible = false; });
298
 
299
  setupUIListeners();
 
300
  animate();
301
  }
302
 
303
  function setupUIListeners() {
304
+ document.querySelectorAll('input[name="mode"]').forEach(radio => {
305
+ radio.addEventListener('change', (e) => {
306
+ mode = e.target.value;
307
+ controls.enabled = mode === 'camera';
308
+ document.getElementById('brush-mode-container').style.display = mode === 'sculpt' ? 'block' : 'none';
309
+ document.getElementById('paint-options').style.display = mode === 'paint' ? 'block' : 'none';
310
+ });
311
+ });
312
+
313
  document.getElementById('create-terrain').addEventListener('click', () => {
314
  const width = parseInt(document.getElementById('terrain-width').value);
315
  const height = parseInt(document.getElementById('terrain-height').value);
 
318
 
319
  document.getElementById('save-project').addEventListener('click', saveProject);
320
  document.getElementById('load-project').addEventListener('click', loadProject);
 
321
  document.getElementById('brush-size').addEventListener('input', (e) => {
322
  document.getElementById('brush-size-value').textContent = e.target.value;
323
  updateBrushHelper();
324
  });
 
325
  document.getElementById('brush-strength').addEventListener('input', (e) => {
326
  document.getElementById('brush-strength-value').textContent = e.target.value;
327
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  document.getElementById('project-list').addEventListener('change', (e) => {
329
  document.getElementById('project-name').value = e.target.value;
330
  });
331
+ document.getElementById('show-water').addEventListener('change', (e) => {
332
+ toggleWater(e.target.checked);
 
 
 
333
  });
334
+ document.getElementById('show-grass').addEventListener('change', (e) => {
335
+ if (e.target.checked) {
336
+ generateGrass();
337
+ } else {
338
+ removeGrass();
339
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  });
341
  }
342
 
 
344
  document.getElementById('loading-spinner').style.display = show ? 'block' : 'none';
345
  }
346
 
347
+ function createTerrain(width, height, data = null, splatData = null) {
348
  if (terrainMesh) {
349
  scene.remove(terrainMesh);
350
  terrainMesh.geometry.dispose();
 
369
  for (let i = 0; i < positions.count; i++) {
370
  positions.setY(i, data[i]);
371
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  }
373
 
374
  const material = new THREE.MeshStandardMaterial({
 
 
375
  roughness: 0.8,
376
  metalness: 0.2
377
  });
378
+
379
+ material.onBeforeCompile = (shader) => {
380
+ shader.uniforms.splat = { value: splatTexture };
381
+ shader.vertexShader = shader.vertexShader.replace(
382
+ '#include <common>',
383
+ '#include <common>\nvarying vec2 vUv;'
384
+ );
385
+ shader.vertexShader = shader.vertexShader.replace(
386
+ '#include <begin_vertex>',
387
+ '#include <begin_vertex>\nvUv = uv;'
388
+ );
389
+ shader.fragmentShader = shader.fragmentShader.replace(
390
+ '#include <common>',
391
+ '#include <common>\nuniform sampler2D splat;'
392
+ );
393
+ shader.fragmentShader = shader.fragmentShader.replace(
394
+ '#include <color_fragment>',
395
+ '#include <color_fragment>\nvec4 splatCol = texture2D(splat, vUv);\ndiffuseColor.rgb = vec3(0.1, 0.6, 0.1) * splatCol.r + vec3(0.5, 0.3, 0.1) * splatCol.g + vec3(0.4, 0.4, 0.4) * splatCol.b;'
396
+ );
397
+ };
398
+
399
  terrainMesh = new THREE.Mesh(geometry, material);
400
  terrainMesh.castShadow = true;
401
  terrainMesh.receiveShadow = true;
402
  scene.add(terrainMesh);
403
 
404
  geometry.computeVertexNormals();
405
+
406
+ const splatArray = new Uint8Array(splatRes * splatRes * 4);
407
+ for (let k = 0; k < splatArray.length; k += 4) {
408
+ splatArray[k] = 255;
409
+ splatArray[k + 3] = 255;
410
+ }
411
+ splatTexture = new THREE.DataTexture(splatArray, splatRes, splatRes, THREE.RGBAFormat);
412
+ splatTexture.needsUpdate = true;
413
+
414
+ if (splatData) {
415
+ splatTexture.image.data = new Uint8Array(splatData);
416
+ splatTexture.needsUpdate = true;
417
+ }
418
  }
419
 
420
  function onWindowResize() {
 
439
  brushHelper.visible = true;
440
  updateBrushHelper();
441
 
442
+ if (isModifying && mode !== 'camera') {
443
+ modifyTerrain(intersects[0]);
 
 
 
 
444
  }
445
  } else {
446
  brushHelper.visible = false;
 
453
  }
454
 
455
  function onPointerDown(event) {
456
+ if (event.button === 0 && mode !== 'camera') {
457
+ isModifying = true;
458
  }
459
  }
460
 
461
  function onPointerUp(event) {
462
  if (event.button === 0) {
463
+ isModifying = false;
464
  }
465
  }
466
 
467
+ function modifyTerrain(intersect) {
468
  if (!terrainMesh) return;
 
 
 
 
469
 
470
+ if (mode === 'sculpt') {
471
+ const positions = terrainMesh.geometry.attributes.position;
472
+ const brushSize = parseFloat(document.getElementById('brush-size').value);
473
+ const brushStrength = parseFloat(document.getElementById('brush-strength').value);
474
+ const brushMode = document.querySelector('input[name="brush-mode"]:checked').value;
475
+ const direction = (brushMode === 'raise') ? 1 : -1;
476
+
477
+ const center = intersect.point;
478
+ const vertex = new THREE.Vector3();
479
+ for (let i = 0; i < positions.count; i++) {
480
+ vertex.fromBufferAttribute(positions, i);
481
+ const distance = vertex.distanceTo(center);
482
+
483
+ if (distance < brushSize) {
484
+ const falloff = (1 - (distance / brushSize));
485
+ const smoothedFalloff = falloff * falloff * (3 - 2 * falloff);
486
 
487
+ let currentY = positions.getY(i);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
  let newY = currentY + direction * smoothedFalloff * brushStrength;
489
  positions.setY(i, newY);
490
  }
491
  }
492
+ positions.needsUpdate = true;
493
+ terrainMesh.geometry.computeVertexNormals();
494
+ } else if (mode === 'paint') {
495
+ const uv = intersect.uv;
496
+ const brushSize = parseFloat(document.getElementById('brush-size').value);
497
+ const brushStrength = parseFloat(document.getElementById('brush-strength').value);
498
+ const selectedChannel = parseInt(document.getElementById('paint-texture').value);
499
+
500
+ const data = splatTexture.image.data;
501
+ for (let j = 0; j < splatRes; j++) {
502
+ for (let i = 0; i < splatRes; i++) {
503
+ const u = i / (splatRes - 1);
504
+ const v = j / (splatRes - 1);
505
+ const du = u - uv.x;
506
+ const dv = v - uv.y;
507
+ const distX = du * terrainDimensions.width;
508
+ const distZ = dv * terrainDimensions.height;
509
+ const dist = Math.sqrt(distX * distX + distZ * distZ);
510
+ if (dist < brushSize) {
511
+ const falloff = 1 - dist / brushSize;
512
+ const smoothed = falloff * falloff * (3 - 2 * falloff);
513
+ const amount = smoothed * brushStrength;
514
+ const index = (j * splatRes + i) * 4;
515
+ const targetR = selectedChannel === 0 ? 255 : 0;
516
+ const targetG = selectedChannel === 1 ? 255 : 0;
517
+ const targetB = selectedChannel === 2 ? 255 : 0;
518
+ data[index] = data[index] * (1 - amount) + targetR * amount;
519
+ data[index + 1] = data[index + 1] * (1 - amount) + targetG * amount;
520
+ data[index + 2] = data[index + 2] * (1 - amount) + targetB * amount;
521
+ data[index + 3] = 255;
522
+ }
523
+ }
524
  }
525
+ splatTexture.needsUpdate = true;
526
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
527
  }
528
 
529
+ function getHeightAt(x, z) {
530
+ if (Math.abs(x) > terrainDimensions.width / 2 || Math.abs(z) > terrainDimensions.height / 2) return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  const positions = terrainMesh.geometry.attributes.position;
532
+ const gridX = terrainDimensions.segmentsX + 1;
533
+ const gridZ = terrainDimensions.segmentsY + 1;
534
+ const cellSizeX = terrainDimensions.width / terrainDimensions.segmentsX;
535
+ const cellSizeZ = terrainDimensions.height / terrainDimensions.segmentsY;
536
+ const gridPosX = (x + terrainDimensions.width / 2) / cellSizeX;
537
+ const gridPosZ = (z + terrainDimensions.height / 2) / cellSizeZ;
538
+ const cellX = Math.floor(gridPosX);
539
+ const cellZ = Math.floor(gridPosZ);
540
+ if (cellX < 0 || cellX >= terrainDimensions.segmentsX || cellZ < 0 || cellZ >= terrainDimensions.segmentsY) return null;
541
+ const v00 = positions.getY(cellZ * gridX + cellX);
542
+ const v10 = positions.getY(cellZ * gridX + cellX + 1);
543
+ const v01 = positions.getY((cellZ + 1) * gridX + cellX);
544
+ const v11 = positions.getY((cellZ + 1) * gridX + cellX + 1);
545
+ const fracX = gridPosX - cellX;
546
+ const fracZ = gridPosZ - cellZ;
547
+ const y1 = v00 * (1 - fracX) + v10 * fracX;
548
+ const y2 = v01 * (1 - fracX) + v11 * fracX;
549
+ return y1 * (1 - fracZ) + y2 * fracZ;
550
+ }
551
+
552
+ function getSplatAt(u, v) {
553
+ const i = Math.floor(u * (splatRes - 1));
554
+ const j = Math.floor(v * (splatRes - 1));
555
+ const index = (j * splatRes + i) * 4;
556
+ const data = splatTexture.image.data;
557
+ return [data[index] / 255, data[index + 1] / 255, data[index + 2] / 255];
558
+ }
559
+
560
+ function generateGrass() {
561
+ removeGrass();
562
+ grassGroup = new THREE.Group();
563
+ const grassGeo = new THREE.PlaneGeometry(0.5, 1);
564
+ grassGeo.translate(0, 0.5, 0);
565
+ const grassMat = new THREE.MeshStandardMaterial({ color: 0x00ff00, side: THREE.DoubleSide });
566
+ const count = 5000;
567
+ const instancedGrass = new THREE.InstancedMesh(grassGeo, grassMat, count);
568
+ const dummy = new THREE.Object3D();
569
+ let idx = 0;
570
+ while (idx < count) {
571
+ const x = Math.random() * terrainDimensions.width - terrainDimensions.width / 2;
572
+ const z = Math.random() * terrainDimensions.height - terrainDimensions.height / 2;
573
+ const y = getHeightAt(x, z);
574
+ if (y === null) continue;
575
+ const uvx = (x + terrainDimensions.width / 2) / terrainDimensions.width;
576
+ const uvz = (z + terrainDimensions.height / 2) / terrainDimensions.height;
577
+ const splatVal = getSplatAt(uvx, uvz);
578
+ if (splatVal[0] > 0.5) {
579
+ dummy.position.set(x, y, z);
580
+ dummy.rotation.y = Math.random() * Math.PI * 2;
581
+ dummy.scale.set(1, 1 + Math.random(), 1);
582
+ dummy.updateMatrix();
583
+ instancedGrass.setMatrixAt(idx, dummy.matrix);
584
+ idx++;
585
+ }
586
  }
587
+ instancedGrass.instanceMatrix.needsUpdate = true;
588
+ grassGroup.add(instancedGrass);
589
+ scene.add(grassGroup);
590
  }
 
 
 
 
 
 
 
591
 
592
+ function removeGrass() {
593
+ if (grassGroup) {
594
+ scene.remove(grassGroup);
595
+ grassGroup = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  }
 
 
597
  }
598
+
599
+ function toggleWater(show) {
600
+ if (show) {
601
+ if (!waterMesh) {
602
+ const waterGeo = new THREE.PlaneGeometry(terrainDimensions.width * 2, terrainDimensions.height * 2);
603
+ waterGeo.rotateX(-Math.PI / 2);
604
+ const waterMat = new THREE.MeshStandardMaterial({ color: 0x0066ff, transparent: true, opacity: 0.6, side: THREE.DoubleSide });
605
+ waterMesh = new THREE.Mesh(waterGeo, waterMat);
606
+ waterMesh.position.y = 0;
607
+ scene.add(waterMesh);
608
+ }
609
+ } else if (waterMesh) {
610
+ scene.remove(waterMesh);
611
  }
612
  }
613
 
 
634
  width: terrainDimensions.width,
635
  height: terrainDimensions.height,
636
  data: heightData,
637
+ splatData: Array.from(splatTexture.image.data),
638
+ showWater: document.getElementById('show-water').checked,
639
+ showGrass: document.getElementById('show-grass').checked
640
  };
641
 
642
  try {
 
661
  }
662
 
663
  } catch (error) {
 
664
  alert(`Не удалось сохранить проект: ${error.message}`);
665
  } finally {
666
  showSpinner(false);
 
683
  throw new Error(result.error || 'Ошибка при загрузке проекта.');
684
  }
685
 
686
+ createTerrain(result.width, result.height, result.data, result.splatData);
687
  document.getElementById('project-name').value = projectName;
688
  document.getElementById('terrain-width').value = result.width;
689
  document.getElementById('terrain-height').value = result.height;
690
+ document.getElementById('show-water').checked = result.showWater || false;
691
+ toggleWater(result.showWater || false);
692
+ document.getElementById('show-grass').checked = result.showGrass || false;
693
+ if (result.showGrass) {
694
+ generateGrass();
695
+ }
696
  } catch (error) {
 
697
  alert(`Не удалось загрузить проект: ${error.message}`);
698
  } finally {
699
  showSpinner(false);
 
749
 
750
  if __name__ == '__main__':
751
  port = int(os.environ.get('PORT', 7860))
752
+ app.run(debug=False, host='0.0.0.0', port=port)