maps4fs 1.8.13__py3-none-any.whl → 1.8.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,21 +10,15 @@ from copy import deepcopy
10
10
 
11
11
  import cv2
12
12
  import numpy as np
13
- import trimesh # type: ignore
14
- from tqdm import tqdm
15
13
 
16
- from maps4fs.generator.component.base.component import Component
14
+ from maps4fs.generator.component.base.component_image import ImageComponent
15
+ from maps4fs.generator.component.base.component_mesh import MeshComponent
17
16
  from maps4fs.generator.dem import DEM
17
+ from maps4fs.generator.settings import Parameters
18
18
  from maps4fs.generator.texture import Texture
19
19
 
20
- DEFAULT_DISTANCE = 2048
21
- FULL_NAME = "FULL"
22
- FULL_PREVIEW_NAME = "PREVIEW"
23
- ELEMENTS = [FULL_NAME, FULL_PREVIEW_NAME]
24
20
 
25
-
26
- # pylint: disable=R0902
27
- class Background(Component):
21
+ class Background(MeshComponent, ImageComponent):
28
22
  """Component for creating 3D obj files based on DEM data around the map.
29
23
 
30
24
  Arguments:
@@ -38,11 +32,9 @@ class Background(Component):
38
32
  info, warning. If not provided, default logging will be used.
39
33
  """
40
34
 
41
- # pylint: disable=R0801
42
35
  def preprocess(self) -> None:
43
36
  """Registers the DEMs for the background terrain."""
44
- self.stl_preview_path: str | None = None
45
- self.water_resources_path: str | None = None
37
+ self.stl_preview_path = os.path.join(self.previews_directory, "background_dem.stl")
46
38
 
47
39
  if self.rotation:
48
40
  self.logger.debug("Rotation is enabled: %s.", self.rotation)
@@ -50,7 +42,7 @@ class Background(Component):
50
42
  else:
51
43
  output_size_multiplier = 1
52
44
 
53
- self.background_size = self.map_size + DEFAULT_DISTANCE * 2
45
+ self.background_size = self.map_size + Parameters.BACKGROUND_DISTANCE * 2
54
46
  self.rotated_size = int(self.background_size * output_size_multiplier)
55
47
 
56
48
  self.background_directory = os.path.join(self.map_directory, "background")
@@ -58,13 +50,17 @@ class Background(Component):
58
50
  os.makedirs(self.background_directory, exist_ok=True)
59
51
  os.makedirs(self.water_directory, exist_ok=True)
60
52
 
61
- self.output_path = os.path.join(self.background_directory, f"{FULL_NAME}.png")
53
+ self.water_resources_path = os.path.join(self.water_directory, "water_resources.png")
54
+
55
+ self.output_path = os.path.join(self.background_directory, f"{Parameters.FULL}.png")
62
56
  if self.map.custom_background_path:
63
- self.check_custom_background(self.map.custom_background_path)
57
+ self.validate_np_for_mesh(self.map.custom_background_path, self.map_size)
64
58
  shutil.copyfile(self.map.custom_background_path, self.output_path)
65
59
 
66
- self.not_substracted_path = os.path.join(self.background_directory, "not_substracted.png")
67
- self.not_resized_path = os.path.join(self.background_directory, "not_resized.png")
60
+ self.not_substracted_path: str = os.path.join(
61
+ self.background_directory, "not_substracted.png"
62
+ )
63
+ self.not_resized_path: str = os.path.join(self.background_directory, "not_resized.png")
68
64
 
69
65
  self.dem = DEM(
70
66
  self.game,
@@ -80,39 +76,6 @@ class Background(Component):
80
76
  self.dem.set_output_resolution((self.rotated_size, self.rotated_size))
81
77
  self.dem.set_dem_path(self.output_path)
82
78
 
83
- def check_custom_background(self, image_path: str) -> None:
84
- """Checks if the custom background image meets the requirements.
85
-
86
- Arguments:
87
- image_path (str): The path to the custom background image.
88
-
89
- Raises:
90
- ValueError: If the custom background image does not meet the requirements.
91
- """
92
- image = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
93
- if image.shape[0] != image.shape[1]:
94
- raise ValueError("The custom background image must be a square.")
95
-
96
- if image.shape[0] != self.map_size + DEFAULT_DISTANCE * 2:
97
- raise ValueError("The custom background image must have the size of the map + 4096.")
98
-
99
- if len(image.shape) != 2:
100
- raise ValueError("The custom background image must be a grayscale image.")
101
-
102
- if image.dtype != np.uint16:
103
- raise ValueError("The custom background image must be a 16-bit grayscale image.")
104
-
105
- def is_preview(self, name: str) -> bool:
106
- """Checks if the DEM is a preview.
107
-
108
- Arguments:
109
- name (str): The name of the DEM.
110
-
111
- Returns:
112
- bool: True if the DEM is a preview, False otherwise.
113
- """
114
- return name == FULL_PREVIEW_NAME
115
-
116
79
  def process(self) -> None:
117
80
  """Launches the component processing. Iterates over all tiles and processes them
118
81
  as a result the DEM files will be saved, then based on them the obj files will be
@@ -123,12 +86,12 @@ class Background(Component):
123
86
  self.dem.process()
124
87
 
125
88
  shutil.copyfile(self.dem.dem_path, self.not_substracted_path)
126
- self.cutout(self.dem.dem_path, save_path=self.not_resized_path)
89
+ self.save_map_dem(self.dem.dem_path, save_path=self.not_resized_path)
127
90
 
128
91
  if self.map.dem_settings.water_depth:
129
92
  self.subtraction()
130
93
 
131
- cutted_dem_path = self.cutout(self.dem.dem_path)
94
+ cutted_dem_path = self.save_map_dem(self.dem.dem_path)
132
95
  if self.game.additional_dem_name is not None:
133
96
  self.make_copy(cutted_dem_path, self.game.additional_dem_name)
134
97
 
@@ -184,9 +147,9 @@ class Background(Component):
184
147
 
185
148
  def qgis_sequence(self) -> None:
186
149
  """Generates QGIS scripts for creating bounding box layers and rasterizing them."""
187
- qgis_layer = (f"Background_{FULL_NAME}", *self.dem.get_espg3857_bbox())
150
+ qgis_layer = (f"Background_{Parameters.FULL}", *self.dem.get_espg3857_bbox())
188
151
  qgis_layer_with_margin = (
189
- f"Background_{FULL_NAME}_margin",
152
+ f"Background_{Parameters.FULL}_margin",
190
153
  *self.dem.get_espg3857_bbox(add_margin=True),
191
154
  )
192
155
  self.create_qgis_scripts([qgis_layer, qgis_layer_with_margin])
@@ -214,10 +177,9 @@ class Background(Component):
214
177
  create_preview=True,
215
178
  remove_center=self.map.background_settings.remove_center,
216
179
  include_zeros=False,
217
- ) # type: ignore
180
+ )
218
181
 
219
- # pylint: disable=too-many-locals
220
- def cutout(self, dem_path: str, save_path: str | None = None) -> str:
182
+ def save_map_dem(self, dem_path: str, save_path: str | None = None) -> str:
221
183
  """Cuts out the center of the DEM (the actual map) and saves it as a separate file.
222
184
 
223
185
  Arguments:
@@ -228,14 +190,8 @@ class Background(Component):
228
190
  str -- The path to the cutout DEM file.
229
191
  """
230
192
  dem_data = cv2.imread(dem_path, cv2.IMREAD_UNCHANGED)
231
-
232
- center = (dem_data.shape[0] // 2, dem_data.shape[1] // 2)
233
193
  half_size = self.map_size // 2
234
- x1 = center[0] - half_size
235
- x2 = center[0] + half_size
236
- y1 = center[1] - half_size
237
- y2 = center[1] + half_size
238
- dem_data = dem_data[x1:x2, y1:y2]
194
+ dem_data = self.cut_out_np(dem_data, half_size, return_cutout=True)
239
195
 
240
196
  if save_path:
241
197
  cv2.imwrite(save_path, dem_data)
@@ -260,26 +216,6 @@ class Background(Component):
260
216
 
261
217
  return main_dem_path
262
218
 
263
- def remove_center(self, dem_data: np.ndarray, resize_factor: float) -> np.ndarray:
264
- """Removes the center part of the DEM data.
265
-
266
- Arguments:
267
- dem_data (np.ndarray) -- The DEM data as a numpy array.
268
- resize_factor (float) -- The resize factor of the DEM data.
269
-
270
- Returns:
271
- np.ndarray -- The DEM data with the center part removed.
272
- """
273
- center = (dem_data.shape[0] // 2, dem_data.shape[1] // 2)
274
- half_size = int(self.map_size // 2 * resize_factor)
275
- x1 = center[0] - half_size
276
- x2 = center[0] + half_size
277
- y1 = center[1] - half_size
278
- y2 = center[1] + half_size
279
- dem_data[x1:x2, y1:y2] = 0
280
- return dem_data
281
-
282
- # pylint: disable=R0913, R0917, R0915
283
219
  def plane_from_np(
284
220
  self,
285
221
  dem_data: np.ndarray,
@@ -302,110 +238,29 @@ class Background(Component):
302
238
  resize_factor = 1 / self.map.background_settings.resize_factor
303
239
  dem_data = cv2.resize(dem_data, (0, 0), fx=resize_factor, fy=resize_factor)
304
240
  if remove_center:
305
- dem_data = self.remove_center(dem_data, resize_factor)
241
+ half_size = int(self.map_size // 2 * resize_factor)
242
+ dem_data = self.cut_out_np(dem_data, half_size, set_zeros=True)
306
243
  self.logger.debug("Center removed from DEM data.")
307
244
  self.logger.debug(
308
245
  "DEM data resized to shape: %s with factor: %s", dem_data.shape, resize_factor
309
246
  )
310
247
 
311
- # Invert the height values.
312
- dem_data = dem_data.max() - dem_data
313
-
314
- rows, cols = dem_data.shape
315
- x = np.linspace(0, cols - 1, cols)
316
- y = np.linspace(0, rows - 1, rows)
317
- x, y = np.meshgrid(x, y)
318
- z = dem_data
319
-
320
- ground = z.max()
321
- self.logger.debug("Ground level: %s", ground)
322
-
323
- self.logger.debug(
324
- "Starting to generate a mesh for with shape: %s x %s. This may take a while.",
325
- cols,
326
- rows,
248
+ mesh = self.mesh_from_np(
249
+ dem_data,
250
+ include_zeros=include_zeros,
251
+ z_scaling_factor=self.get_z_scaling_factor(),
252
+ resize_factor=resize_factor,
253
+ apply_decimation=self.map.background_settings.apply_decimation,
254
+ decimation_percent=self.map.background_settings.decimation_percent,
255
+ decimation_agression=self.map.background_settings.decimation_agression,
327
256
  )
328
257
 
329
- vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
330
- faces = []
331
-
332
- skipped = 0
333
-
334
- for i in tqdm(range(rows - 1), desc="Generating mesh", unit="row"):
335
- for j in range(cols - 1):
336
- top_left = i * cols + j
337
- top_right = top_left + 1
338
- bottom_left = top_left + cols
339
- bottom_right = bottom_left + 1
340
-
341
- if (
342
- ground in [z[i, j], z[i, j + 1], z[i + 1, j], z[i + 1, j + 1]]
343
- and not include_zeros
344
- ):
345
- skipped += 1
346
- continue
347
-
348
- faces.append([top_left, bottom_left, bottom_right])
349
- faces.append([top_left, bottom_right, top_right])
350
-
351
- self.logger.debug("Skipped faces: %s", skipped)
352
-
353
- faces = np.array(faces) # type: ignore
354
- mesh = trimesh.Trimesh(vertices=vertices, faces=faces)
355
-
356
- # Apply rotation: 180 degrees around Y-axis and Z-axis
357
- rotation_matrix_y = trimesh.transformations.rotation_matrix(np.pi, [0, 1, 0])
358
- rotation_matrix_z = trimesh.transformations.rotation_matrix(np.pi, [0, 0, 1])
359
- mesh.apply_transform(rotation_matrix_y)
360
- mesh.apply_transform(rotation_matrix_z)
361
-
362
- # if not include_zeros:
363
- z_scaling_factor = self.get_z_scaling_factor()
364
- self.logger.debug("Z scaling factor: %s", z_scaling_factor)
365
- mesh.apply_scale([1 / resize_factor, 1 / resize_factor, z_scaling_factor])
366
-
367
- old_faces = len(mesh.faces)
368
- self.logger.debug("Mesh generated with %s faces.", old_faces)
369
-
370
- if self.map.background_settings.apply_decimation:
371
- percent = self.map.background_settings.decimation_percent / 100
372
- mesh = mesh.simplify_quadric_decimation(
373
- percent=percent, aggression=self.map.background_settings.decimation_agression
374
- )
375
-
376
- new_faces = len(mesh.faces)
377
- decimation_percent = (old_faces - new_faces) / old_faces * 100
378
-
379
- self.logger.debug(
380
- "Mesh simplified to %s faces. Decimation percent: %s", new_faces, decimation_percent
381
- )
382
-
383
258
  mesh.export(save_path)
384
259
  self.logger.debug("Obj file saved: %s", save_path)
385
260
 
386
261
  if create_preview:
387
- # Simplify the preview mesh to reduce the size of the file.
388
- # mesh = mesh.simplify_quadric_decimation(face_count=len(mesh.faces) // 2**7)
389
-
390
- # Apply scale to make the preview mesh smaller in the UI.
391
262
  mesh.apply_scale([0.5, 0.5, 0.5])
392
- self.mesh_to_stl(mesh)
393
-
394
- def mesh_to_stl(self, mesh: trimesh.Trimesh) -> None:
395
- """Converts the mesh to an STL file and saves it in the previews directory.
396
- Uses powerful simplification to reduce the size of the file since it will be used
397
- only for the preview.
398
-
399
- Arguments:
400
- mesh (trimesh.Trimesh) -- The mesh to convert to an STL file.
401
- """
402
- mesh = mesh.simplify_quadric_decimation(face_count=len(mesh.faces) // 2**6)
403
- preview_path = os.path.join(self.previews_directory, "background_dem.stl")
404
- mesh.export(preview_path)
405
-
406
- self.logger.debug("STL file saved: %s", preview_path)
407
-
408
- self.stl_preview_path = preview_path # pylint: disable=attribute-defined-outside-init
263
+ self.mesh_to_stl(mesh, save_path=self.stl_preview_path)
409
264
 
410
265
  def previews(self) -> list[str]:
411
266
  """Returns the path to the image previews paths and the path to the STL preview file.
@@ -421,8 +276,13 @@ class Background(Component):
421
276
  background_dem_preview_image = cv2.resize(
422
277
  background_dem_preview_image, (0, 0), fx=1 / 4, fy=1 / 4
423
278
  )
424
- background_dem_preview_image = cv2.normalize( # type: ignore
425
- background_dem_preview_image, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U
279
+ background_dem_preview_image = cv2.normalize(
280
+ background_dem_preview_image,
281
+ dst=np.empty_like(background_dem_preview_image),
282
+ alpha=0,
283
+ beta=255,
284
+ norm_type=cv2.NORM_MINMAX,
285
+ dtype=cv2.CV_8U,
426
286
  )
427
287
  background_dem_preview_image = cv2.cvtColor(
428
288
  background_dem_preview_image, cv2.COLOR_GRAY2BGR
@@ -431,7 +291,7 @@ class Background(Component):
431
291
  cv2.imwrite(background_dem_preview_path, background_dem_preview_image)
432
292
  preview_paths.append(background_dem_preview_path)
433
293
 
434
- if self.stl_preview_path:
294
+ if os.path.isfile(self.stl_preview_path):
435
295
  preview_paths.append(self.stl_preview_path)
436
296
 
437
297
  return preview_paths
@@ -483,26 +343,11 @@ class Background(Component):
483
343
 
484
344
  dem_data = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
485
345
 
486
- self.logger.debug(
487
- "DEM data before normalization. Shape: %s, dtype: %s. Min: %s, max: %s.",
488
- dem_data.shape,
489
- dem_data.dtype,
490
- dem_data.min(),
491
- dem_data.max(),
492
- )
493
-
494
346
  # Create an empty array with the same shape and type as dem_data.
495
347
  dem_data_normalized = np.empty_like(dem_data)
496
348
 
497
349
  # Normalize the DEM data to the range [0, 255]
498
350
  cv2.normalize(dem_data, dem_data_normalized, 0, 255, cv2.NORM_MINMAX)
499
- self.logger.debug(
500
- "DEM data after normalization. Shape: %s, dtype: %s. Min: %s, max: %s.",
501
- dem_data_normalized.shape,
502
- dem_data_normalized.dtype,
503
- dem_data_normalized.min(),
504
- dem_data_normalized.max(),
505
- )
506
351
  dem_data_colored = cv2.applyColorMap(dem_data_normalized, cv2.COLORMAP_JET)
507
352
 
508
353
  cv2.imwrite(colored_dem_path, dem_data_colored)
@@ -528,7 +373,7 @@ class Background(Component):
528
373
  if not background_layers:
529
374
  return
530
375
 
531
- self.background_texture = Texture( # pylint: disable=W0201
376
+ self.background_texture = Texture(
532
377
  self.game,
533
378
  self.map,
534
379
  self.coordinates,
@@ -555,13 +400,10 @@ class Background(Component):
555
400
  # Merge all images into one.
556
401
  background_image = np.zeros((self.background_size, self.background_size), dtype=np.uint8)
557
402
  for path in background_paths:
558
- layer = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
559
- background_image = cv2.add(background_image, layer) # type: ignore
403
+ background_layer = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
404
+ background_image = cv2.add(background_image, background_layer) # type: ignore
560
405
 
561
- background_save_path = os.path.join(self.water_directory, "water_resources.png")
562
- cv2.imwrite(background_save_path, background_image)
563
- self.logger.debug("Background texture saved: %s", background_save_path)
564
- self.water_resources_path = background_save_path # pylint: disable=W0201
406
+ cv2.imwrite(self.water_resources_path, background_image)
565
407
 
566
408
  def subtraction(self) -> None:
567
409
  """Subtracts the water depth from the DEM data where the water resources are located."""
@@ -569,20 +411,14 @@ class Background(Component):
569
411
  self.logger.warning("Water resources texture not found.")
570
412
  return
571
413
 
572
- # Single channeled 8 bit image, where the water have values of 255, and the rest 0.
573
414
  water_resources_image = cv2.imread(self.water_resources_path, cv2.IMREAD_UNCHANGED)
574
- mask = water_resources_image == 255
575
-
576
- # Make mask a little bit smaller (1 pixel).
577
- mask = cv2.erode(mask.astype(np.uint8), np.ones((3, 3), np.uint8), iterations=1).astype(
578
- bool
579
- )
580
-
581
415
  dem_image = cv2.imread(self.output_path, cv2.IMREAD_UNCHANGED)
582
416
 
583
- # Create a mask where water_resources_image is 255 (or not 0)
584
- # Subtract water_depth from dem_image where mask is True
585
- dem_image[mask] = dem_image[mask] - self.map.dem_settings.water_depth
417
+ dem_image = self.subtract_by_mask(
418
+ dem_image,
419
+ water_resources_image,
420
+ self.map.dem_settings.water_depth,
421
+ )
586
422
 
587
423
  # Save the modified dem_image back to the output path
588
424
  cv2.imwrite(self.output_path, dem_image)
@@ -590,7 +426,7 @@ class Background(Component):
590
426
 
591
427
  def generate_water_resources_obj(self) -> None:
592
428
  """Generates 3D obj files based on water resources data."""
593
- if not self.water_resources_path:
429
+ if not os.path.isfile(self.water_resources_path):
594
430
  self.logger.warning("Water resources texture not found.")
595
431
  return
596
432
 
@@ -0,0 +1,90 @@
1
+ """Base class for all components that primarily used to work with images."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+
6
+ from maps4fs.generator.component.base.component import Component
7
+
8
+
9
+ class ImageComponent(Component):
10
+ """Base class for all components that primarily used to work with images."""
11
+
12
+ @staticmethod
13
+ def polygon_points_to_np(
14
+ polygon_points: list[tuple[int, int]], divide: int | None = None
15
+ ) -> np.ndarray:
16
+ """Converts the polygon points to a NumPy array.
17
+
18
+ Arguments:
19
+ polygon_points (list[tuple[int, int]]): The polygon points.
20
+ divide (int, optional): The number to divide the points by. Defaults to None.
21
+
22
+ Returns:
23
+ np.array: The NumPy array of the polygon points.
24
+ """
25
+ array = np.array(polygon_points, dtype=np.int32).reshape((-1, 1, 2))
26
+ if divide:
27
+ return array // divide
28
+ return array
29
+
30
+ @staticmethod
31
+ def cut_out_np(
32
+ image: np.ndarray, half_size: int, set_zeros: bool = False, return_cutout: bool = False
33
+ ) -> np.ndarray:
34
+ """Cuts out a square from the center of the image.
35
+
36
+ Arguments:
37
+ image (np.ndarray): The image.
38
+ half_size (int): The half size of the square.
39
+ set_zeros (bool, optional): Whether to set the cutout to zeros. Defaults to False.
40
+ return_cutout (bool, optional): Whether to return the cutout. Defaults to False.
41
+
42
+ Returns:
43
+ np.ndarray: The image with the cutout or the cutout itself.
44
+ """
45
+ center = (image.shape[0] // 2, image.shape[1] // 2)
46
+ x1 = center[0] - half_size
47
+ x2 = center[0] + half_size
48
+ y1 = center[1] - half_size
49
+ y2 = center[1] + half_size
50
+
51
+ if return_cutout:
52
+ return image[x1:x2, y1:y2]
53
+
54
+ if set_zeros:
55
+ image[x1:x2, y1:y2] = 0
56
+
57
+ return image
58
+
59
+ @staticmethod
60
+ def subtract_by_mask(
61
+ image: np.ndarray,
62
+ image_mask: np.ndarray,
63
+ subtract_by: int,
64
+ mask_by: int = 255,
65
+ erode_kernel: int | None = 3,
66
+ erode_iter: int | None = 1,
67
+ ) -> np.ndarray:
68
+ """Subtracts a value from the image where the mask is equal to the mask by value.
69
+
70
+ Arguments:
71
+ image (np.ndarray): The image.
72
+ image_mask (np.ndarray): The mask.
73
+ subtract_by (int): The value to subtract by.
74
+ mask_by (int, optional): The value to mask by. Defaults to 255.
75
+ erode_kernel (int, optional): The kernel size for the erosion. Defaults to 3.
76
+ erode_iter (int, optional): The number of iterations for the erosion. Defaults to 1.
77
+
78
+ Returns:
79
+ np.ndarray: The image with the subtracted value.
80
+ """
81
+ mask = image_mask == mask_by
82
+ if erode_kernel and erode_iter:
83
+ mask = cv2.erode(
84
+ mask.astype(np.uint8),
85
+ np.ones((erode_kernel, erode_kernel), np.uint8),
86
+ iterations=erode_iter,
87
+ ).astype(bool)
88
+
89
+ image[mask] = image[mask] - subtract_by
90
+ return image
@@ -0,0 +1,125 @@
1
+ """Base class for all components that primarily used to work with meshes."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+ import trimesh
6
+ from tqdm import tqdm
7
+
8
+ from maps4fs.generator.component.base.component import Component
9
+ from maps4fs.generator.settings import Parameters
10
+
11
+
12
+ class MeshComponent(Component):
13
+ """Base class for all components that primarily used to work with meshes."""
14
+
15
+ @staticmethod
16
+ def validate_np_for_mesh(image_path: str, map_size: int) -> None:
17
+ """Checks if the given image is a valid for mesh generation.
18
+
19
+ Arguments:
20
+ image_path (str): The path to the custom background image.
21
+ map_size (int): The size of the map.
22
+
23
+ Raises:
24
+ ValueError: If the custom background image does not meet the requirements.
25
+ """
26
+ image = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
27
+ if image.shape[0] != image.shape[1]:
28
+ raise ValueError("The custom background image must be a square.")
29
+
30
+ if image.shape[0] != map_size + Parameters.BACKGROUND_DISTANCE * 2:
31
+ raise ValueError("The custom background image must have the size of the map + 4096.")
32
+
33
+ if len(image.shape) != 2:
34
+ raise ValueError("The custom background image must be a grayscale image.")
35
+
36
+ if image.dtype != np.uint16:
37
+ raise ValueError("The custom background image must be a 16-bit grayscale image.")
38
+
39
+ @staticmethod
40
+ def mesh_to_stl(mesh: trimesh.Trimesh, save_path: str) -> None:
41
+ """Converts the mesh to an STL file and saves it in the previews directory.
42
+ Uses powerful simplification to reduce the size of the file since it will be used
43
+ only for the preview.
44
+
45
+ Arguments:
46
+ mesh (trimesh.Trimesh) -- The mesh to convert to an STL file.
47
+ """
48
+ mesh = mesh.simplify_quadric_decimation(face_count=len(mesh.faces) // 2**6)
49
+ mesh.export(save_path)
50
+
51
+ @staticmethod
52
+ def mesh_from_np(
53
+ image: np.ndarray,
54
+ include_zeros: bool,
55
+ z_scaling_factor: float,
56
+ resize_factor: float,
57
+ apply_decimation: bool,
58
+ decimation_percent: int,
59
+ decimation_agression: int,
60
+ ) -> trimesh.Trimesh:
61
+ """Generates a mesh from the given numpy array.
62
+
63
+ Arguments:
64
+ image (np.ndarray): The numpy array to generate the mesh from.
65
+ include_zeros (bool): Whether to include zero values in the mesh.
66
+ z_scaling_factor (float): The scaling factor for the Z-axis.
67
+ resize_factor (float): The resizing factor.
68
+ apply_decimation (bool): Whether to apply decimation to the mesh.
69
+ decimation_percent (int): The percent of the decimation.
70
+ decimation_agression (int): The agression of the decimation.
71
+
72
+ Returns:
73
+ trimesh.Trimesh: The generated mesh.
74
+ """
75
+ image = image.max() - image
76
+
77
+ rows, cols = image.shape
78
+ x = np.linspace(0, cols - 1, cols)
79
+ y = np.linspace(0, rows - 1, rows)
80
+ x, y = np.meshgrid(x, y)
81
+ z = image
82
+
83
+ ground = z.max()
84
+
85
+ vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
86
+ faces = []
87
+
88
+ skipped = 0
89
+
90
+ for i in tqdm(range(rows - 1), desc="Generating mesh", unit="row"):
91
+ for j in range(cols - 1):
92
+ top_left = i * cols + j
93
+ top_right = top_left + 1
94
+ bottom_left = top_left + cols
95
+ bottom_right = bottom_left + 1
96
+
97
+ if (
98
+ ground in [z[i, j], z[i, j + 1], z[i + 1, j], z[i + 1, j + 1]]
99
+ and not include_zeros
100
+ ):
101
+ skipped += 1
102
+ continue
103
+
104
+ faces.append([top_left, bottom_left, bottom_right])
105
+ faces.append([top_left, bottom_right, top_right])
106
+
107
+ faces_np = np.array(faces)
108
+ mesh = trimesh.Trimesh(vertices=vertices, faces=faces_np)
109
+
110
+ # Apply rotation: 180 degrees around Y-axis and Z-axis
111
+ rotation_matrix_y = trimesh.transformations.rotation_matrix(np.pi, [0, 1, 0])
112
+ rotation_matrix_z = trimesh.transformations.rotation_matrix(np.pi, [0, 0, 1])
113
+ mesh.apply_transform(rotation_matrix_y)
114
+ mesh.apply_transform(rotation_matrix_z)
115
+
116
+ # if not include_zeros:
117
+ mesh.apply_scale([1 / resize_factor, 1 / resize_factor, z_scaling_factor])
118
+
119
+ if apply_decimation:
120
+ percent = decimation_percent / 100
121
+ mesh = mesh.simplify_quadric_decimation(
122
+ percent=percent, aggression=decimation_agression
123
+ )
124
+
125
+ return mesh
@@ -93,3 +93,16 @@ class XMLComponent(Component):
93
93
  for key, value in data.items():
94
94
  element.set(key, value)
95
95
  return element
96
+
97
+ def create_subelement(
98
+ self, parent: ET.Element, element_name: str, data: dict[str, str]
99
+ ) -> None:
100
+ """Creates a subelement under the parent element with the provided data.
101
+
102
+ Arguments:
103
+ parent (ET.Element): The parent element.
104
+ element_name (str): The name of the subelement.
105
+ data (dict[str, str]): The data to set the subelement attributes to.
106
+ """
107
+ element = ET.SubElement(parent, element_name)
108
+ self.update_element(element, data)