maps4fs 0.9.93__py3-none-any.whl → 1.1.6__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.
@@ -4,7 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
- import warnings
7
+ import re
8
+ from collections import defaultdict
8
9
  from typing import Any, Callable, Generator, Optional
9
10
 
10
11
  import cv2
@@ -36,11 +37,14 @@ class Texture(Component):
36
37
  """Class which represents a layer with textures and tags.
37
38
  It's using to obtain data from OSM using tags and make changes into corresponding textures.
38
39
 
39
- Args:
40
+ Arguments:
40
41
  name (str): Name of the layer.
41
42
  tags (dict[str, str | list[str]]): Dictionary of tags to search for.
42
43
  width (int | None): Width of the polygon in meters (only for LineString).
43
44
  color (tuple[int, int, int]): Color of the layer in BGR format.
45
+ exclude_weight (bool): Flag to exclude weight from the texture.
46
+ priority (int | None): Priority of the layer.
47
+ info_layer (str | None): Name of the corresnponding info layer.
44
48
 
45
49
  Attributes:
46
50
  name (str): Name of the layer.
@@ -58,6 +62,7 @@ class Texture(Component):
58
62
  color: tuple[int, int, int] | list[int] | None = None,
59
63
  exclude_weight: bool = False,
60
64
  priority: int | None = None,
65
+ info_layer: str | None = None,
61
66
  ):
62
67
  self.name = name
63
68
  self.count = count
@@ -66,6 +71,7 @@ class Texture(Component):
66
71
  self.color = color if color else (255, 255, 255)
67
72
  self.exclude_weight = exclude_weight
68
73
  self.priority = priority
74
+ self.info_layer = info_layer
69
75
 
70
76
  def to_json(self) -> dict[str, str | list[str] | bool]: # type: ignore
71
77
  """Returns dictionary with layer data.
@@ -80,6 +86,7 @@ class Texture(Component):
80
86
  "color": list(self.color),
81
87
  "exclude_weight": self.exclude_weight,
82
88
  "priority": self.priority,
89
+ "info_layer": self.info_layer,
83
90
  }
84
91
 
85
92
  data = {k: v for k, v in data.items() if v is not None}
@@ -89,7 +96,7 @@ class Texture(Component):
89
96
  def from_json(cls, data: dict[str, str | list[str] | bool]) -> Texture.Layer:
90
97
  """Creates a new instance of the class from dictionary.
91
98
 
92
- Args:
99
+ Arguments:
93
100
  data (dict[str, str | list[str] | bool]): Dictionary with layer data.
94
101
 
95
102
  Returns:
@@ -110,7 +117,64 @@ class Texture(Component):
110
117
  weight_postfix = "_weight" if not self.exclude_weight else ""
111
118
  return os.path.join(weights_directory, f"{self.name}{idx}{weight_postfix}.png")
112
119
 
120
+ def path_preview(self, previews_directory: str) -> str:
121
+ """Returns path to the preview of the first texture of the layer.
122
+
123
+ Arguments:
124
+ previews_directory (str): Path to the directory with previews.
125
+
126
+ Returns:
127
+ str: Path to the preview.
128
+ """
129
+ return self.path(previews_directory).replace(".png", "_preview.png")
130
+
131
+ def get_preview_or_path(self, previews_directory: str) -> str:
132
+ """Returns path to the preview of the first texture of the layer if it exists,
133
+ otherwise returns path to the texture.
134
+
135
+ Arguments:
136
+ previews_directory (str): Path to the directory with previews.
137
+
138
+ Returns:
139
+ str: Path to the preview or texture.
140
+ """
141
+ preview_path = self.path_preview(previews_directory)
142
+ return preview_path if os.path.isfile(preview_path) else self.path(previews_directory)
143
+
144
+ def paths(self, weights_directory: str) -> list[str]:
145
+ """Returns a list of paths to the textures of the layer.
146
+ NOTE: Works only after the textures are generated, since it just lists the directory.
147
+
148
+ Arguments:
149
+ weights_directory (str): Path to the directory with weights.
150
+
151
+ Returns:
152
+ list[str]: List of paths to the textures.
153
+ """
154
+ weight_files = os.listdir(weights_directory)
155
+
156
+ # Inconsistent names are the name of textures that are not following the pattern
157
+ # of texture_name{idx}_weight.png.
158
+ inconsistent_names = ["forestRockRoot", "waterPuddle"]
159
+
160
+ if self.name in inconsistent_names:
161
+ return [
162
+ os.path.join(weights_directory, weight_file)
163
+ for weight_file in weight_files
164
+ if weight_file.startswith(self.name)
165
+ ]
166
+
167
+ return [
168
+ os.path.join(weights_directory, weight_file)
169
+ for weight_file in weight_files
170
+ if re.match(rf"{self.name}\d{{2}}_weight.png", weight_file)
171
+ ]
172
+
113
173
  def preprocess(self) -> None:
174
+ self.light_version = self.kwargs.get("light_version", False)
175
+ self.fields_padding = self.kwargs.get("fields_padding", 0)
176
+ self.logger.debug("Light version: %s.", self.light_version)
177
+
114
178
  if not os.path.isfile(self.game.texture_schema):
115
179
  raise FileNotFoundError(f"Texture layers schema not found: {self.game.texture_schema}")
116
180
 
@@ -134,6 +198,9 @@ class Texture(Component):
134
198
  self.info_save_path = os.path.join(self.map_directory, "generation_info.json")
135
199
  self.logger.debug("Generation info save path: %s.", self.info_save_path)
136
200
 
201
+ self.info_layer_path = os.path.join(self.info_layers_directory, "textures.json")
202
+ self.logger.debug("Info layer path: %s.", self.info_layer_path)
203
+
137
204
  def get_base_layer(self) -> Layer | None:
138
205
  """Returns base layer.
139
206
 
@@ -149,6 +216,31 @@ class Texture(Component):
149
216
  self._prepare_weights()
150
217
  self._read_parameters()
151
218
  self.draw()
219
+ self.rotate_textures()
220
+
221
+ def rotate_textures(self) -> None:
222
+ """Rotates textures of the layers which have tags."""
223
+ if self.rotation:
224
+ # Iterate over the layers which have tags and rotate them.
225
+ for layer in self.layers:
226
+ if layer.tags:
227
+ self.logger.debug("Rotating layer %s.", layer.name)
228
+ layer_paths = layer.paths(self._weights_dir)
229
+ layer_paths += [layer.path_preview(self._weights_dir)]
230
+ for layer_path in layer_paths:
231
+ if os.path.isfile(layer_path):
232
+ self.rotate_image(
233
+ layer_path,
234
+ self.rotation,
235
+ output_height=self.map_size,
236
+ output_width=self.map_size,
237
+ )
238
+ else:
239
+ self.logger.warning("Layer path %s not found.", layer_path)
240
+ else:
241
+ self.logger.debug(
242
+ "Skipping rotation of layer %s because it has no tags.", layer.name
243
+ )
152
244
 
153
245
  # pylint: disable=W0201
154
246
  def _read_parameters(self) -> None:
@@ -186,16 +278,16 @@ class Texture(Component):
186
278
 
187
279
  for layer in self.layers:
188
280
  self._generate_weights(layer)
189
- self.logger.debug("Prepared weights for %s layers.", len(self.layers))
281
+ self.logger.info("Prepared weights for %s layers.", len(self.layers))
190
282
 
191
283
  def _generate_weights(self, layer: Layer) -> None:
192
284
  """Generates weight files for textures. Each file is a numpy array of zeros and
193
285
  dtype uint8 (0-255).
194
286
 
195
- Args:
287
+ Arguments:
196
288
  layer (Layer): Layer with textures and tags.
197
289
  """
198
- size = (self.map_height, self.map_width)
290
+ size = (self.map_rotated_size, self.map_rotated_size)
199
291
  postfix = "_weight.png" if not layer.exclude_weight else ".png"
200
292
  if layer.count == 0:
201
293
  filepaths = [os.path.join(self._weights_dir, layer.name + postfix)]
@@ -222,7 +314,7 @@ class Texture(Component):
222
314
  def layers(self, layers: list[Layer]) -> None:
223
315
  """Sets list of layers with textures and tags.
224
316
 
225
- Args:
317
+ Arguments:
226
318
  layers (list[Layer]): List of layers.
227
319
  """
228
320
  self._layers = layers
@@ -253,6 +345,10 @@ class Texture(Component):
253
345
 
254
346
  cumulative_image = None
255
347
 
348
+ # Dictionary to store info layer data.
349
+ # Key is a layer.info_layer, value is a list of polygon points as tuples (x, y).
350
+ info_layer_data = defaultdict(list)
351
+
256
352
  for layer in layers:
257
353
  if not layer.tags:
258
354
  self.logger.debug("Layer %s has no tags, there's nothing to draw.", layer.name)
@@ -273,6 +369,8 @@ class Texture(Component):
273
369
  mask = cv2.bitwise_not(cumulative_image)
274
370
 
275
371
  for polygon in self.polygons(layer.tags, layer.width): # type: ignore
372
+ if layer.info_layer:
373
+ info_layer_data[layer.info_layer].append(self.np_to_polygon_points(polygon))
276
374
  cv2.fillPoly(layer_image, [polygon], color=255) # type: ignore
277
375
 
278
376
  output_image = cv2.bitwise_and(layer_image, mask)
@@ -280,16 +378,76 @@ class Texture(Component):
280
378
  cumulative_image = cv2.bitwise_or(cumulative_image, output_image)
281
379
 
282
380
  cv2.imwrite(layer_path, output_image)
283
- self.logger.debug("Texture %s saved.", layer_path)
381
+ self.logger.info("Texture %s saved.", layer_path)
382
+
383
+ # Save info layer data.
384
+ with open(self.info_layer_path, "w", encoding="utf-8") as f:
385
+ json.dump(info_layer_data, f, ensure_ascii=False, indent=4)
284
386
 
285
387
  if cumulative_image is not None:
286
388
  self.draw_base_layer(cumulative_image)
287
389
 
390
+ if not self.light_version:
391
+ self.dissolve()
392
+ else:
393
+ self.logger.debug("Skipping dissolve in light version of the map.")
394
+
395
+ def dissolve(self) -> None:
396
+ """Dissolves textures of the layers with tags into sublayers for them to look more
397
+ natural in the game.
398
+ Iterates over all layers with tags and reads the first texture, checks if the file
399
+ contains any non-zero values (255), splits those non-values between different weight
400
+ files of the corresponding layer and saves the changes to the files.
401
+ """
402
+ for layer in self.layers:
403
+ if not layer.tags:
404
+ self.logger.debug("Layer %s has no tags, there's nothing to dissolve.", layer.name)
405
+ continue
406
+ layer_path = layer.path(self._weights_dir)
407
+ layer_paths = layer.paths(self._weights_dir)
408
+
409
+ if len(layer_paths) < 2:
410
+ self.logger.debug("Layer %s has only one texture, skipping.", layer.name)
411
+ continue
412
+
413
+ self.logger.debug("Dissolving layer from %s to %s.", layer_path, layer_paths)
414
+
415
+ # Check if the image contains any non-zero values, otherwise continue.
416
+ layer_image = cv2.imread(layer_path, cv2.IMREAD_UNCHANGED)
417
+
418
+ if not np.any(layer_image):
419
+ self.logger.debug(
420
+ "Layer %s does not contain any non-zero values, skipping.", layer.name
421
+ )
422
+ continue
423
+
424
+ # Save the original image to use it for preview later, without combining the sublayers.
425
+ cv2.imwrite(layer.path_preview(self._weights_dir), layer_image.copy())
426
+
427
+ # Get the coordinates of non-zero values.
428
+ non_zero_coords = np.column_stack(np.where(layer_image > 0))
429
+
430
+ # Prepare sublayers.
431
+ sublayers = [np.zeros_like(layer_image) for _ in range(layer.count)]
432
+
433
+ # Randomly assign non-zero values to sublayers.
434
+ for coord in non_zero_coords:
435
+ sublayers[np.random.randint(0, layer.count)][coord[0], coord[1]] = 255
436
+
437
+ # Save the sublayers.
438
+ for sublayer, sublayer_path in zip(sublayers, layer_paths):
439
+ cv2.imwrite(sublayer_path, sublayer)
440
+ self.logger.debug("Sublayer %s saved.", sublayer_path)
441
+
442
+ self.logger.info("Dissolved layer %s.", layer.name)
443
+
444
+ self.logger.info("Dissolving finished.")
445
+
288
446
  def draw_base_layer(self, cumulative_image: np.ndarray) -> None:
289
447
  """Draws base layer and saves it into the png file.
290
448
  Base layer is the last layer to be drawn, it fills the remaining area of the map.
291
449
 
292
- Args:
450
+ Arguments:
293
451
  cumulative_image (np.ndarray): Cumulative image with all layers.
294
452
  """
295
453
  base_layer = self.get_base_layer()
@@ -298,37 +456,50 @@ class Texture(Component):
298
456
  self.logger.debug("Drawing base layer %s.", layer_path)
299
457
  img = cv2.bitwise_not(cumulative_image)
300
458
  cv2.imwrite(layer_path, img)
301
- self.logger.debug("Base texture %s saved.", layer_path)
459
+ self.logger.info("Base texture %s saved.", layer_path)
302
460
 
303
461
  def get_relative_x(self, x: float) -> int:
304
462
  """Converts UTM X coordinate to relative X coordinate in map image.
305
463
 
306
- Args:
464
+ Arguments:
307
465
  x (float): UTM X coordinate.
308
466
 
309
467
  Returns:
310
468
  int: Relative X coordinate in map image.
311
469
  """
312
- return int(self.map_width * (x - self.minimum_x) / (self.maximum_x - self.minimum_x))
470
+ return int(self.map_rotated_size * (x - self.minimum_x) / (self.maximum_x - self.minimum_x))
313
471
 
314
472
  def get_relative_y(self, y: float) -> int:
315
473
  """Converts UTM Y coordinate to relative Y coordinate in map image.
316
474
 
317
- Args:
475
+ Arguments:
318
476
  y (float): UTM Y coordinate.
319
477
 
320
478
  Returns:
321
479
  int: Relative Y coordinate in map image.
322
480
  """
323
- return int(self.map_height * (1 - (y - self.minimum_y) / (self.maximum_y - self.minimum_y)))
481
+ return int(
482
+ self.map_rotated_size * (1 - (y - self.minimum_y) / (self.maximum_y - self.minimum_y))
483
+ )
484
+
485
+ def np_to_polygon_points(self, np_array: np.ndarray) -> list[tuple[int, int]]:
486
+ """Converts numpy array of polygon points to list of tuples.
487
+
488
+ Arguments:
489
+ np_array (np.ndarray): Numpy array of polygon points.
490
+
491
+ Returns:
492
+ list[tuple[int, int]]: List of tuples.
493
+ """
494
+ return [(int(x), int(y)) for x, y in np_array.reshape(-1, 2)]
324
495
 
325
496
  # pylint: disable=W0613
326
497
  def _to_np(self, geometry: shapely.geometry.polygon.Polygon, *args) -> np.ndarray:
327
498
  """Converts Polygon geometry to numpy array of polygon points.
328
499
 
329
- Args:
500
+ Arguments:
330
501
  geometry (shapely.geometry.polygon.Polygon): Polygon geometry.
331
- *args: Additional arguments:
502
+ *Arguments: Additional arguments:
332
503
  - width (int | None): Width of the polygon in meters.
333
504
 
334
505
  Returns:
@@ -340,15 +511,17 @@ class Texture(Component):
340
511
  pairs = list(zip(xs, ys))
341
512
  return np.array(pairs, dtype=np.int32).reshape((-1, 1, 2))
342
513
 
343
- def _to_polygon(self, obj: pd.core.series.Series, width: int | None) -> np.ndarray | None:
514
+ def _to_polygon(
515
+ self, obj: pd.core.series.Series, width: int | None
516
+ ) -> shapely.geometry.polygon.Polygon:
344
517
  """Converts OSM object to numpy array of polygon points.
345
518
 
346
- Args:
519
+ Arguments:
347
520
  obj (pd.core.series.Series): OSM object.
348
521
  width (int | None): Width of the polygon in meters.
349
522
 
350
523
  Returns:
351
- np.ndarray | None: Numpy array of polygon points.
524
+ shapely.geometry.polygon.Polygon: Polygon geometry.
352
525
  """
353
526
  geometry = obj["geometry"]
354
527
  geometry_type = geometry.geom_type
@@ -362,32 +535,45 @@ class Texture(Component):
362
535
  self,
363
536
  geometry: shapely.geometry.linestring.LineString | shapely.geometry.point.Point,
364
537
  width: int | None,
365
- ) -> np.ndarray:
538
+ ) -> shapely.geometry.polygon.Polygon:
366
539
  """Converts LineString or Point geometry to numpy array of polygon points.
367
540
 
368
- Args:
541
+ Arguments:
369
542
  geometry (shapely.geometry.linestring.LineString | shapely.geometry.point.Point):
370
543
  LineString or Point geometry.
371
544
  width (int | None): Width of the polygon in meters.
372
545
 
373
546
  Returns:
374
- np.ndarray: Numpy array of polygon points.
547
+ shapely.geometry.polygon.Polygon: Polygon geometry.
375
548
  """
376
549
  polygon = geometry.buffer(width)
377
- return self._to_np(polygon)
550
+ return polygon
551
+
552
+ def _skip(
553
+ self, geometry: shapely.geometry.polygon.Polygon, *args, **kwargs
554
+ ) -> shapely.geometry.polygon.Polygon:
555
+ """Returns the same geometry.
556
+
557
+ Arguments:
558
+ geometry (shapely.geometry.polygon.Polygon): Polygon geometry.
559
+
560
+ Returns:
561
+ shapely.geometry.polygon.Polygon: Polygon geometry.
562
+ """
563
+ return geometry
378
564
 
379
565
  def _converters(
380
566
  self, geom_type: str
381
567
  ) -> Optional[Callable[[BaseGeometry, Optional[int]], np.ndarray]]:
382
568
  """Returns a converter function for a given geometry type.
383
569
 
384
- Args:
570
+ Arguments:
385
571
  geom_type (str): Geometry type.
386
572
 
387
573
  Returns:
388
574
  Callable[[shapely.geometry, int | None], np.ndarray]: Converter function.
389
575
  """
390
- converters = {"Polygon": self._to_np, "LineString": self._sequence, "Point": self._sequence}
576
+ converters = {"Polygon": self._skip, "LineString": self._sequence, "Point": self._sequence}
391
577
  return converters.get(geom_type) # type: ignore
392
578
 
393
579
  def polygons(
@@ -395,29 +581,38 @@ class Texture(Component):
395
581
  ) -> Generator[np.ndarray, None, None]:
396
582
  """Generator which yields numpy arrays of polygons from OSM data.
397
583
 
398
- Args:
584
+ Arguments:
399
585
  tags (dict[str, str | list[str]]): Dictionary of tags to search for.
400
586
  width (int | None): Width of the polygon in meters (only for LineString).
401
587
 
402
588
  Yields:
403
589
  Generator[np.ndarray, None, None]: Numpy array of polygon points.
404
590
  """
591
+ is_fieds = "farmland" in tags.values()
405
592
  try:
406
- with warnings.catch_warnings():
407
- warnings.simplefilter("ignore", DeprecationWarning)
408
- objects = ox.features_from_bbox(bbox=self.bbox, tags=tags)
593
+ objects = ox.features_from_bbox(bbox=self.new_bbox, tags=tags)
409
594
  except Exception as e: # pylint: disable=W0718
410
595
  self.logger.warning("Error fetching objects for tags: %s.", tags)
411
596
  self.logger.warning(e)
412
597
  return
413
- objects_utm = ox.project_gdf(objects, to_latlong=False)
598
+ objects_utm = ox.projection.project_gdf(objects, to_latlong=False)
414
599
  self.logger.debug("Fetched %s elements for tags: %s.", len(objects_utm), tags)
415
600
 
416
601
  for _, obj in objects_utm.iterrows():
417
602
  polygon = self._to_polygon(obj, width)
418
603
  if polygon is None:
419
604
  continue
420
- yield polygon
605
+
606
+ if is_fieds and self.fields_padding > 0:
607
+ padded_polygon = polygon.buffer(-self.fields_padding)
608
+
609
+ if not isinstance(padded_polygon, shapely.geometry.polygon.Polygon):
610
+ self.logger.warning("The padding value is too high, field will not padded.")
611
+ else:
612
+ polygon = padded_polygon
613
+
614
+ polygon_np = self._to_np(polygon)
615
+ yield polygon_np
421
616
 
422
617
  def previews(self) -> list[str]:
423
618
  """Invokes methods to generate previews. Returns list of paths to previews.
@@ -436,13 +631,11 @@ class Texture(Component):
436
631
  Returns:
437
632
  str: Path to the preview.
438
633
  """
439
- scaling_factor = min(
440
- PREVIEW_MAXIMUM_SIZE / self.map_width, PREVIEW_MAXIMUM_SIZE / self.map_height
441
- )
634
+ scaling_factor = PREVIEW_MAXIMUM_SIZE / self.map_size
442
635
 
443
636
  preview_size = (
444
- int(self.map_width * scaling_factor),
445
- int(self.map_height * scaling_factor),
637
+ int(self.map_size * scaling_factor),
638
+ int(self.map_size * scaling_factor),
446
639
  )
447
640
  self.logger.debug(
448
641
  "Scaling factor: %s. Preview size: %s.",
@@ -455,7 +648,8 @@ class Texture(Component):
455
648
 
456
649
  images = [
457
650
  cv2.resize(
458
- cv2.imread(layer.path(self._weights_dir), cv2.IMREAD_UNCHANGED), preview_size
651
+ cv2.imread(layer.get_preview_or_path(self._weights_dir), cv2.IMREAD_UNCHANGED),
652
+ preview_size,
459
653
  )
460
654
  for layer in active_layers
461
655
  ]
@@ -472,6 +666,7 @@ class Texture(Component):
472
666
  merged.dtype,
473
667
  )
474
668
  preview_path = os.path.join(self.previews_directory, "textures_osm.png")
475
- cv2.imwrite(preview_path, merged) # pylint: disable=no-member
669
+
670
+ cv2.imwrite(preview_path, merged) # type: ignore
476
671
  self.logger.info("Preview saved to %s.", preview_path)
477
672
  return preview_path
maps4fs/logger.py CHANGED
@@ -4,9 +4,7 @@ import logging
4
4
  import os
5
5
  import sys
6
6
  from datetime import datetime
7
- from logging import getLogger
8
- from time import perf_counter
9
- from typing import Any, Callable, Literal
7
+ from typing import Literal
10
8
 
11
9
  LOGGER_NAME = "maps4fs"
12
10
  log_directory = os.path.join(os.getcwd(), "logs")
@@ -46,25 +44,3 @@ class Logger(logging.Logger):
46
44
  today = datetime.now().strftime("%Y-%m-%d")
47
45
  log_file = os.path.join(log_directory, f"{today}.txt")
48
46
  return log_file
49
-
50
-
51
- def timeit(func: Callable[..., Any]) -> Callable[..., Any]:
52
- """Decorator to log the time taken by a function to execute.
53
-
54
- Args:
55
- func (function): The function to be timed.
56
-
57
- Returns:
58
- function: The timed function.
59
- """
60
-
61
- def timed(*args, **kwargs):
62
- logger = getLogger("maps4fs")
63
- start = perf_counter()
64
- result = func(*args, **kwargs)
65
- end = perf_counter()
66
- if logger is not None:
67
- logger.info("Function %s took %s seconds to execute", func.__name__, end - start)
68
- return result
69
-
70
- return timed
@@ -0,0 +1,63 @@
1
+ """This module contains functions to work with the background terrain of the map."""
2
+
3
+ import cv2
4
+ import numpy as np
5
+ import trimesh # type: ignore
6
+
7
+
8
+ # pylint: disable=R0801, R0914
9
+ def plane_from_np(
10
+ dem_data: np.ndarray,
11
+ resize_factor: float,
12
+ simplify_factor: int,
13
+ save_path: str,
14
+ ) -> None:
15
+ """Generates a 3D obj file based on DEM data.
16
+
17
+ Arguments:
18
+ dem_data (np.ndarray) -- The DEM data as a numpy array.
19
+ resize_factor (float) -- The factor by which the DEM data will be resized. Bigger values
20
+ will result in a bigger mesh.
21
+ simplify_factor (int) -- The factor by which the mesh will be simplified. Bigger values
22
+ will result in a simpler mesh.
23
+ save_path (str) -- The path to save the obj file.
24
+ """
25
+ dem_data = cv2.resize( # pylint: disable=no-member
26
+ dem_data, (0, 0), fx=resize_factor, fy=resize_factor
27
+ )
28
+
29
+ # Invert the height values.
30
+ dem_data = dem_data.max() - dem_data
31
+
32
+ rows, cols = dem_data.shape
33
+ x = np.linspace(0, cols - 1, cols)
34
+ y = np.linspace(0, rows - 1, rows)
35
+ x, y = np.meshgrid(x, y)
36
+ z = dem_data
37
+
38
+ vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
39
+ faces = []
40
+
41
+ for i in range(rows - 1):
42
+ for j in range(cols - 1):
43
+ top_left = i * cols + j
44
+ top_right = top_left + 1
45
+ bottom_left = top_left + cols
46
+ bottom_right = bottom_left + 1
47
+
48
+ faces.append([top_left, bottom_left, bottom_right])
49
+ faces.append([top_left, bottom_right, top_right])
50
+
51
+ faces = np.array(faces) # type: ignore
52
+ mesh = trimesh.Trimesh(vertices=vertices, faces=faces)
53
+
54
+ # Apply rotation: 180 degrees around Y-axis and Z-axis
55
+ rotation_matrix_y = trimesh.transformations.rotation_matrix(np.pi, [0, 1, 0])
56
+ rotation_matrix_z = trimesh.transformations.rotation_matrix(np.pi, [0, 0, 1])
57
+ mesh.apply_transform(rotation_matrix_y)
58
+ mesh.apply_transform(rotation_matrix_z)
59
+
60
+ # Simplify the mesh to reduce the number of faces.
61
+ mesh = mesh.simplify_quadric_decimation(face_count=len(faces) // simplify_factor)
62
+
63
+ mesh.export(save_path)
maps4fs/toolbox/dem.py CHANGED
@@ -11,7 +11,7 @@ from rasterio.windows import from_bounds # type: ignore
11
11
  def read_geo_tiff(file_path: str) -> DatasetReader:
12
12
  """Read a GeoTIFF file and return the DatasetReader object.
13
13
 
14
- Args:
14
+ Arguments:
15
15
  file_path (str): The path to the GeoTIFF file.
16
16
 
17
17
  Raises:
@@ -43,7 +43,7 @@ def get_geo_tiff_bbox(
43
43
  ) -> tuple[float, float, float, float]:
44
44
  """Return the bounding box of a GeoTIFF file in the destination CRS.
45
45
 
46
- Args:
46
+ Arguments:
47
47
  src (DatasetReader): The DatasetReader object for the GeoTIFF file.
48
48
  dst_crs (str, optional): The destination CRS. Defaults to "EPSG:4326".
49
49
 
@@ -65,7 +65,7 @@ def get_geo_tiff_bbox(
65
65
  def extract_roi(file_path: str, bbox: tuple[float, float, float, float]) -> str:
66
66
  """Extract a region of interest (ROI) from a GeoTIFF file and save it as a new file.
67
67
 
68
- Args:
68
+ Arguments:
69
69
  file_path (str): The path to the GeoTIFF file.
70
70
  bbox (tuple[float, float, float, float]): The bounding box of the region of interest
71
71
  as (north, south, east, west).