maps4fs 1.7.2__py3-none-any.whl → 1.7.4__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.
maps4fs/__init__.py CHANGED
@@ -2,6 +2,7 @@
2
2
  from maps4fs.generator.dtm.dtm import DTMProvider
3
3
  from maps4fs.generator.dtm.srtm import SRTM30Provider, SRTM30ProviderSettings
4
4
  from maps4fs.generator.dtm.usgs import USGSProvider, USGSProviderSettings
5
+ from maps4fs.generator.dtm.nrw import NRWProvider, NRWProviderSettings
5
6
  from maps4fs.generator.game import Game
6
7
  from maps4fs.generator.map import Map
7
8
  from maps4fs.generator.settings import (
@@ -11,7 +11,10 @@ from typing import TYPE_CHECKING, Type
11
11
  import numpy as np
12
12
  import osmnx as ox # type: ignore
13
13
  import rasterio # type: ignore
14
- import requests
14
+ from rasterio.warp import calculate_default_transform, reproject
15
+ from rasterio.enums import Resampling
16
+ from rasterio.merge import merge
17
+
15
18
  from pydantic import BaseModel
16
19
 
17
20
  from maps4fs.logger import Logger
@@ -23,8 +26,11 @@ if TYPE_CHECKING:
23
26
  class DTMProviderSettings(BaseModel):
24
27
  """Base class for DTM provider settings models."""
25
28
 
29
+ easy_mode: bool = True
30
+ power_factor: int = 0
31
+
26
32
 
27
- # pylint: disable=too-many-public-methods
33
+ # pylint: disable=too-many-public-methods, too-many-instance-attributes
28
34
  class DTMProvider(ABC):
29
35
  """Base class for DTM providers."""
30
36
 
@@ -44,6 +50,21 @@ class DTMProvider(ABC):
44
50
 
45
51
  _instructions: str | None = None
46
52
 
53
+ _base_instructions = (
54
+ "ℹ️ Using **Easy mode** is recommended, as it automatically adjusts the values in the "
55
+ "image, so the terrain elevation in Giants Editor will match real world "
56
+ "elevation in meters. \n"
57
+ "ℹ️ If the terrain height difference in the real world is bigger than 255 meters, "
58
+ "the [Height scale](https://github.com/iwatkot/maps4fs/blob/main/docs/dem.md#height-scale)"
59
+ " parameter in the **map.i3d** file will be automatically adjusted. \n"
60
+ "⚡ If the **Easy mode** option is disabled, you will probably get completely flat "
61
+ "terrain, unless you adjust the DEM Multiplier Setting or the Height scale parameter in "
62
+ "the Giants Editor. \n"
63
+ "💡 You can use the **Power factor** setting to make the difference between heights "
64
+ "bigger. Be extremely careful with this setting, and use only low values, otherwise your "
65
+ "terrain may be completely broken. \n"
66
+ )
67
+
47
68
  # pylint: disable=R0913, R0917
48
69
  def __init__(
49
70
  self,
@@ -169,6 +190,15 @@ class DTMProvider(ABC):
169
190
  """
170
191
  return cls._instructions
171
192
 
193
+ @classmethod
194
+ def base_instructions(cls) -> str | None:
195
+ """Instructions for using any provider.
196
+
197
+ Returns:
198
+ str: Instructions for using any provider.
199
+ """
200
+ return cls._base_instructions
201
+
172
202
  @property
173
203
  def user_settings(self) -> DTMProviderSettings | None:
174
204
  """User settings of the provider.
@@ -212,63 +242,116 @@ class DTMProvider(ABC):
212
242
  """
213
243
  providers = {}
214
244
  for provider in cls.__subclasses__():
215
- if not provider.is_base():
216
- providers[provider._code] = provider.description() # pylint: disable=W0212
245
+ providers[provider._code] = provider.description() # pylint: disable=W0212
217
246
  return providers # type: ignore
218
247
 
219
- def download_tile(self, output_path: str, **kwargs) -> bool:
220
- """Download a tile from the provider.
221
-
222
- Arguments:
223
- output_path (str): Path to save the downloaded tile.
248
+ @abstractmethod
249
+ def download_tiles(self) -> list[str]:
250
+ """Download tiles from the provider.
224
251
 
225
252
  Returns:
226
- bool: True if the tile was downloaded successfully, False otherwise.
253
+ list: List of paths to the downloaded tiles.
227
254
  """
228
- url = self.formatted_url(**kwargs)
229
- response = requests.get(url, stream=True, timeout=10)
230
- if response.status_code == 200:
231
- with open(output_path, "wb") as file:
232
- for chunk in response.iter_content(chunk_size=1024):
233
- file.write(chunk)
234
- return True
235
- return False
236
-
237
- def get_or_download_tile(self, output_path: str, **kwargs) -> str | None:
238
- """Get or download a tile from the provider.
255
+ raise NotImplementedError
239
256
 
240
- Arguments:
241
- output_path (str): Path to save the downloaded tile.
257
+ def get_numpy(self) -> np.ndarray:
258
+ """Get numpy array of the tile.
259
+ Resulting array must be 16 bit (signed or unsigned) integer and it should be already
260
+ windowed to the bounding box of ROI. It also must have only one channel.
242
261
 
243
262
  Returns:
244
- str: Path to the downloaded tile or None if the tile not exists and was
245
- not downloaded.
263
+ np.ndarray: Numpy array of the tile.
246
264
  """
247
- if not os.path.exists(output_path):
248
- if not self.download_tile(output_path, **kwargs):
249
- return None
250
- return output_path
265
+ # download tiles using DTM provider implementation
266
+ tiles = self.download_tiles()
267
+ self.logger.debug(f"Downloaded {len(tiles)} DEM tiles")
268
+
269
+ # merge tiles if necessary
270
+ if len(tiles) > 1:
271
+ self.logger.debug("Multiple tiles downloaded. Merging tiles")
272
+ tile, _ = self.merge_geotiff(tiles)
273
+ else:
274
+ tile = tiles[0]
275
+
276
+ # determine CRS of the resulting tile and reproject if necessary
277
+ with rasterio.open(tile) as src:
278
+ crs = src.crs
279
+ if crs != "EPSG:4326":
280
+ self.logger.debug(f"Reprojecting GeoTIFF from {crs} to EPSG:4326...")
281
+ tile = self.reproject_geotiff(tile)
282
+
283
+ # extract region of interest from the tile
284
+ data = self.extract_roi(tile)
285
+
286
+ # process elevation data to be compatible with the game
287
+ data = self.process_elevation(data)
251
288
 
252
- def get_tile_parameters(self, *args, **kwargs) -> dict:
253
- """Get parameters for the tile, that will be used to format the URL.
254
- Must be implemented in subclasses.
289
+ return data
290
+
291
+ def process_elevation(self, data: np.ndarray) -> np.ndarray:
292
+ """Process elevation data.
293
+
294
+ Arguments:
295
+ data (np.ndarray): Elevation data.
255
296
 
256
297
  Returns:
257
- dict: Tile parameters to format the URL.
298
+ np.ndarray: Processed elevation data.
258
299
  """
259
- raise NotImplementedError
300
+ self.data_info = {}
301
+ self.add_numpy_params(data, "original")
260
302
 
261
- @abstractmethod
262
- def get_numpy(self) -> np.ndarray:
263
- """Get numpy array of the tile.
264
- Resulting array must be 16 bit (signed or unsigned) integer and it should be already
265
- windowed to the bounding box of ROI. It also must have only one channel.
303
+ data = self.signed_to_unsigned(data)
304
+ self.add_numpy_params(data, "grounded")
305
+
306
+ original_deviation = int(self.data_info["original_deviation"])
307
+ in_game_maximum_height = 65535 // 255
308
+ if original_deviation > in_game_maximum_height:
309
+ suggested_height_scale_multiplier = (
310
+ original_deviation / in_game_maximum_height # type: ignore
311
+ )
312
+ suggested_height_scale_value = int(255 * suggested_height_scale_multiplier)
313
+ else:
314
+ suggested_height_scale_multiplier = 1
315
+ suggested_height_scale_value = 255
316
+
317
+ self.data_info["suggested_height_scale_multiplier"] = suggested_height_scale_multiplier
318
+ self.data_info["suggested_height_scale_value"] = suggested_height_scale_value
319
+
320
+ self.map.shared_settings.height_scale_multiplier = ( # type: ignore
321
+ suggested_height_scale_multiplier
322
+ )
323
+ self.map.shared_settings.height_scale_value = suggested_height_scale_value # type: ignore
324
+
325
+ if self.user_settings.easy_mode: # type: ignore
326
+ try:
327
+ data = self.normalize_dem(data)
328
+ self.add_numpy_params(data, "normalized")
329
+
330
+ normalized_deviation = self.data_info["normalized_deviation"]
331
+ z_scaling_factor = normalized_deviation / original_deviation # type: ignore
332
+ self.data_info["z_scaling_factor"] = z_scaling_factor
333
+
334
+ self.map.shared_settings.mesh_z_scaling_factor = z_scaling_factor # type: ignore
335
+ self.map.shared_settings.change_height_scale = True # type: ignore
336
+
337
+ except Exception as e: # pylint: disable=W0718
338
+ self.logger.error(
339
+ "Failed to normalize DEM data. Error: %s. Using original data.", e
340
+ )
341
+
342
+ return data
343
+
344
+ def info_sequence(self) -> dict[str, int | str | float] | None:
345
+ """Returns the information sequence for the component. Must be implemented in the child
346
+ class. If the component does not have an information sequence, an empty dictionary must be
347
+ returned.
266
348
 
267
349
  Returns:
268
- np.ndarray: Numpy array of the tile.
350
+ dict[str, int | str | float] | None: Information sequence for the component.
269
351
  """
270
- raise NotImplementedError
352
+ return self.data_info
271
353
 
354
+ # region helpers
272
355
  def get_bbox(self) -> tuple[float, float, float, float]:
273
356
  """Get bounding box of the tile based on the center point and size.
274
357
 
@@ -281,6 +364,83 @@ class DTMProvider(ABC):
281
364
  bbox = north, south, east, west
282
365
  return bbox
283
366
 
367
+ def reproject_geotiff(self, input_tiff: str) -> str:
368
+ """Reproject a GeoTIFF file to a new coordinate reference system (CRS).
369
+
370
+ Arguments:
371
+ input_tiff (str): Path to the input GeoTIFF file.
372
+ target_crs (str): Target CRS (e.g., EPSG:4326 for CRS:84).
373
+
374
+ Returns:
375
+ str: Path to the reprojected GeoTIFF file.
376
+ """
377
+ output_tiff = os.path.join(self._tile_directory, "merged.tif")
378
+
379
+ # Open the source GeoTIFF
380
+ self.logger.debug("Reprojecting GeoTIFF to %s CRS...", "EPSG:4326")
381
+ with rasterio.open(input_tiff) as src:
382
+ # Get the transform, width, and height of the target CRS
383
+ transform, width, height = calculate_default_transform(
384
+ src.crs, "EPSG:4326", src.width, src.height, *src.bounds
385
+ )
386
+
387
+ # Update the metadata for the target GeoTIFF
388
+ kwargs = src.meta.copy()
389
+ kwargs.update(
390
+ {"crs": "EPSG:4326", "transform": transform, "width": width, "height": height}
391
+ )
392
+
393
+ # Open the destination GeoTIFF file and reproject
394
+ with rasterio.open(output_tiff, "w", **kwargs) as dst:
395
+ for i in range(1, src.count + 1): # Iterate over all raster bands
396
+ reproject(
397
+ source=rasterio.band(src, i),
398
+ destination=rasterio.band(dst, i),
399
+ src_transform=src.transform,
400
+ src_crs=src.crs,
401
+ dst_transform=transform,
402
+ dst_crs="EPSG:4326",
403
+ resampling=Resampling.nearest, # Choose resampling method
404
+ )
405
+
406
+ self.logger.debug("Reprojected GeoTIFF saved to %s", output_tiff)
407
+ return output_tiff
408
+
409
+ def merge_geotiff(self, input_files: list[str]) -> tuple[str, str]:
410
+ """Merge multiple GeoTIFF files into a single GeoTIFF file.
411
+
412
+ Arguments:
413
+ input_files (list): List of input GeoTIFF files to merge.
414
+ output_file (str): Path to save the merged GeoTIFF file.
415
+ """
416
+ output_file = os.path.join(self._tile_directory, "merged.tif")
417
+ # Open all input GeoTIFF files as datasets
418
+ self.logger.debug("Merging tiff files...")
419
+ datasets = [rasterio.open(file) for file in input_files]
420
+
421
+ # Merge datasets
422
+ crs = datasets[0].crs
423
+ mosaic, out_transform = merge(datasets, nodata=0)
424
+
425
+ # Get metadata from the first file and update it for the output
426
+ out_meta = datasets[0].meta.copy()
427
+ out_meta.update(
428
+ {
429
+ "driver": "GTiff",
430
+ "height": mosaic.shape[1],
431
+ "width": mosaic.shape[2],
432
+ "transform": out_transform,
433
+ "count": mosaic.shape[0], # Number of bands
434
+ }
435
+ )
436
+
437
+ # Write merged GeoTIFF to the output file
438
+ with rasterio.open(output_file, "w", **out_meta) as dest:
439
+ dest.write(mosaic)
440
+
441
+ self.logger.debug("GeoTIFF images merged successfully into %s", output_file)
442
+ return output_file, crs
443
+
284
444
  def extract_roi(self, tile_path: str) -> np.ndarray:
285
445
  """Extract region of interest (ROI) from the GeoTIFF file.
286
446
 
@@ -304,18 +464,86 @@ class DTMProvider(ABC):
304
464
  window.width,
305
465
  window.height,
306
466
  )
307
- data = src.read(1, window=window)
467
+ data = src.read(1, window=window, masked=True)
308
468
  if not data.size > 0:
309
469
  raise ValueError("No data in the tile.")
310
470
 
311
471
  return data
312
472
 
313
- def info_sequence(self) -> dict[str, int | str | float] | None:
314
- """Returns the information sequence for the component. Must be implemented in the child
315
- class. If the component does not have an information sequence, an empty dictionary must be
316
- returned.
473
+ def normalize_dem(self, data: np.ndarray) -> np.ndarray:
474
+ """Normalize DEM data to 16-bit unsigned integer using max height from settings.
475
+
476
+ Arguments:
477
+ data (np.ndarray): DEM data from SRTM file after cropping.
317
478
 
318
479
  Returns:
319
- dict[str, int | str | float] | None: Information sequence for the component.
480
+ np.ndarray: Normalized DEM data.
320
481
  """
321
- return self.data_info
482
+ maximum_height = int(data.max())
483
+ minimum_height = int(data.min())
484
+ deviation = maximum_height - minimum_height
485
+ self.logger.debug(
486
+ "Maximum height: %s. Minimum height: %s. Deviation: %s.",
487
+ maximum_height,
488
+ minimum_height,
489
+ deviation,
490
+ )
491
+ self.logger.debug("Number of unique values in original DEM data: %s.", np.unique(data).size)
492
+
493
+ adjusted_maximum_height = maximum_height * 255
494
+ adjusted_maximum_height = min(adjusted_maximum_height, 65535)
495
+ scaling_factor = adjusted_maximum_height / maximum_height
496
+ self.logger.debug(
497
+ "Adjusted maximum height: %s. Scaling factor: %s.",
498
+ adjusted_maximum_height,
499
+ scaling_factor,
500
+ )
501
+
502
+ if self.user_settings.power_factor: # type: ignore
503
+ power_factor = 1 + self.user_settings.power_factor / 10 # type: ignore
504
+ self.logger.debug(
505
+ "Applying power factor: %s to the DEM data.",
506
+ power_factor,
507
+ )
508
+ data = np.power(data, power_factor).astype(np.uint16)
509
+
510
+ normalized_data = np.round(data * scaling_factor).astype(np.uint16)
511
+ self.logger.debug(
512
+ "Normalized data maximum height: %s. Minimum height: %s. Number of unique values: %s.",
513
+ normalized_data.max(),
514
+ normalized_data.min(),
515
+ np.unique(normalized_data).size,
516
+ )
517
+ return normalized_data
518
+
519
+ def signed_to_unsigned(self, data: np.ndarray, add_one: bool = True) -> np.ndarray:
520
+ """Convert signed 16-bit integer to unsigned 16-bit integer.
521
+
522
+ Arguments:
523
+ data (np.ndarray): DEM data from SRTM file after cropping.
524
+
525
+ Returns:
526
+ np.ndarray: Unsigned DEM data.
527
+ """
528
+ data = data - data.min()
529
+ if add_one:
530
+ data = data + 1
531
+ return data.astype(np.uint16)
532
+
533
+ def add_numpy_params(
534
+ self,
535
+ data: np.ndarray,
536
+ prefix: str,
537
+ ) -> None:
538
+ """Add numpy array parameters to the data_info dictionary.
539
+
540
+ Arguments:
541
+ data (np.ndarray): Numpy array of the tile.
542
+ prefix (str): Prefix for the parameters.
543
+ """
544
+ self.data_info[f"{prefix}_minimum_height"] = int(data.min()) # type: ignore
545
+ self.data_info[f"{prefix}_maximum_height"] = int(data.max()) # type: ignore
546
+ self.data_info[f"{prefix}_deviation"] = int(data.max() - data.min()) # type: ignore
547
+ self.data_info[f"{prefix}_unique_values"] = int(np.unique(data).size) # type: ignore
548
+
549
+ # endregion
@@ -0,0 +1,127 @@
1
+ """This module contains provider of USGS data."""
2
+
3
+ import os
4
+
5
+ import numpy as np
6
+ from owslib.wcs import WebCoverageService
7
+ from owslib.util import Authentication
8
+ from pyproj import Transformer
9
+
10
+ from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
11
+
12
+
13
+ class NRWProviderSettings(DTMProviderSettings):
14
+ """Settings for the USGS provider."""
15
+
16
+
17
+ # pylint: disable=too-many-locals
18
+ class NRWProvider(DTMProvider):
19
+ """Generic provider of WCS sources."""
20
+
21
+ _code = "NRW"
22
+ _name = "North Rhine-Westphalia DGM1"
23
+ _region = "DE"
24
+ _icon = "🇩🇪󠁥󠁢󠁹󠁿"
25
+ _resolution = 1
26
+ _data: np.ndarray | None = None
27
+ _settings = NRWProviderSettings
28
+ _author = "[kbrandwijk](https://github.com/kbrandwijk)"
29
+ _is_community = True
30
+ _instructions = None
31
+
32
+ def __init__(self, *args, **kwargs):
33
+ super().__init__(*args, **kwargs)
34
+ self.shared_tiff_path = os.path.join(self._tile_directory, "shared")
35
+ os.makedirs(self.shared_tiff_path, exist_ok=True)
36
+
37
+ def download_tiles(self) -> list[str]:
38
+ bbox = self.get_bbox()
39
+ bbox = self.transform_bbox(bbox, "EPSG:25832")
40
+
41
+ tiles = self.tile_bbox(bbox, 1000)
42
+
43
+ all_tif_files = self.download_all_tiles(tiles)
44
+ return all_tif_files
45
+
46
+ def tile_bbox(
47
+ self,
48
+ bbox: tuple[float, float, float, float],
49
+ tile_size: int) -> list[tuple[float, float, float, float]]:
50
+ """Tile the bounding box into smaller bounding boxes of a specified size.
51
+
52
+ Arguments:
53
+ bbox (tuple): Bounding box to tile (north, south, east, west).
54
+ tile_size (int): Size of the tiles in meters.
55
+
56
+ Returns:
57
+ list: List of smaller bounding boxes (north, south, east, west).
58
+ """
59
+ north, south, east, west = bbox
60
+ x_coords = np.arange(west, east, tile_size)
61
+ y_coords = np.arange(south, north, tile_size)
62
+ x_coords = np.append(x_coords, east).astype(x_coords.dtype)
63
+ y_coords = np.append(y_coords, north).astype(y_coords.dtype)
64
+
65
+ x_min, y_min = np.meshgrid(x_coords[:-1], y_coords[:-1], indexing="ij")
66
+ x_max, y_max = np.meshgrid(x_coords[1:], y_coords[1:], indexing="ij")
67
+
68
+ tiles = np.stack([x_min.ravel(), y_min.ravel(), x_max.ravel(), y_max.ravel()], axis=1)
69
+
70
+ return tiles
71
+
72
+ def download_all_tiles(self, tiles: list[tuple[float, float, float, float]]) -> list[str]:
73
+ """Download tiles from the NRW provider.
74
+
75
+ Arguments:
76
+ tiles (list): List of tiles to download.
77
+
78
+ Returns:
79
+ list: List of paths to the downloaded GeoTIFF files.
80
+ """
81
+ all_tif_files = []
82
+ wcs = WebCoverageService(
83
+ 'https://www.wcs.nrw.de/geobasis/wcs_nw_dgm',
84
+ auth=Authentication(verify=False),
85
+ timeout=600)
86
+ for tile in tiles:
87
+ file_name = '_'.join(map(str, tile)) + '.tif'
88
+ file_path = os.path.join(self.shared_tiff_path, file_name)
89
+
90
+ if not os.path.exists(file_path):
91
+ output = wcs.getCoverage(
92
+ identifier=['nw_dgm'],
93
+ subsets=[('y', str(tile[0]), str(tile[2])), ('x', str(tile[1]), str(tile[3]))],
94
+ format='image/tiff'
95
+ )
96
+ with open(file_path, 'wb') as f:
97
+ f.write(output.read())
98
+
99
+ all_tif_files.append(file_path)
100
+ return all_tif_files
101
+
102
+ def transform_bbox(
103
+ self,
104
+ bbox: tuple[float, float, float, float],
105
+ to_crs: str) -> tuple[float, float, float, float]:
106
+ """Transform the bounding box to a different coordinate reference system (CRS).
107
+
108
+ Arguments:
109
+ bbox (tuple): Bounding box to transform (north, south, east, west).
110
+ to_crs (str): Target CRS (e.g., EPSG:4326 for CRS:84).
111
+
112
+ Returns:
113
+ tuple: Transformed bounding box (north, south, east, west).
114
+ """
115
+ transformer = Transformer.from_crs("epsg:4326", to_crs)
116
+ north, south, east, west = bbox
117
+ bottom_left_x, bottom_left_y = transformer.transform(xx=south, yy=west)
118
+ top_left_x, top_left_y = transformer.transform(xx=north, yy=west)
119
+ top_right_x, top_right_y = transformer.transform(xx=north, yy=east)
120
+ bottom_right_x, bottom_right_y = transformer.transform(xx=south, yy=east)
121
+
122
+ west = min(bottom_left_y, bottom_right_y)
123
+ east = max(top_left_y, top_right_y)
124
+ south = min(bottom_left_x, top_left_x)
125
+ north = max(bottom_right_x, top_right_x)
126
+
127
+ return north, south, east, west
@@ -7,7 +7,7 @@ import math
7
7
  import os
8
8
  import shutil
9
9
 
10
- import numpy as np
10
+ import requests
11
11
 
12
12
  from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
13
13
 
@@ -15,9 +15,6 @@ from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
15
15
  class SRTM30ProviderSettings(DTMProviderSettings):
16
16
  """Settings for SRTM 30m provider."""
17
17
 
18
- easy_mode: bool = True
19
- power_factor: int = 0
20
-
21
18
 
22
19
  class SRTM30Provider(DTMProvider):
23
20
  """Provider of Shuttle Radar Topography Mission (SRTM) 30m data."""
@@ -32,22 +29,6 @@ class SRTM30Provider(DTMProvider):
32
29
 
33
30
  _author = "[iwatkot](https://github.com/iwatkot)"
34
31
 
35
- _instructions = (
36
- "ℹ️ If you don't know how to work with DEM data, it is recommended to use the "
37
- "**Easy mode** option. It will automatically change the values in the image, so the "
38
- "terrain will be visible in the Giants Editor. If you're an experienced modder, it's "
39
- "recommended to disable this option and work with the DEM data in a usual way. \n"
40
- "ℹ️ If the terrain height difference in the real world is bigger than 255 meters, "
41
- "the [Height scale](https://github.com/iwatkot/maps4fs/blob/main/docs/dem.md#height-scale)"
42
- " parameter in the **map.i3d** file will be changed automatically. \n"
43
- "⚡ If the **Easy mode** option is disabled, you will probably get completely flat "
44
- "terrain, unless you adjust the DEM Multiplier Setting or the Height scale parameter in "
45
- "the Giants Editor. \n"
46
- "💡 You can use the **Power factor** setting to make the difference between heights "
47
- "bigger. Be extremely careful with this setting, and use only low values, otherwise your "
48
- "terrain may be completely broken. \n"
49
- )
50
-
51
32
  _settings = SRTM30ProviderSettings
52
33
 
53
34
  def __init__(self, *args, **kwargs):
@@ -58,169 +39,86 @@ class SRTM30Provider(DTMProvider):
58
39
  os.makedirs(self.gz_directory, exist_ok=True)
59
40
  self.data_info: dict[str, int | str | float] | None = None # type: ignore
60
41
 
61
- def get_tile_parameters(self, *args, **kwargs) -> dict[str, str]:
62
- """Returns latitude band and tile name for SRTM tile from coordinates.
42
+ def download_tiles(self):
43
+ """Download SRTM tiles."""
44
+ north, south, east, west = self.get_bbox()
63
45
 
64
- Arguments:
65
- lat (float): Latitude.
66
- lon (float): Longitude.
46
+ tiles = []
47
+ # Look at each corner of the bbox in case the bbox spans across multiple tiles
48
+ for pair in [(north, east), (south, west), (south, east), (north, west)]:
49
+ tile_parameters = self.get_tile_parameters(*pair)
50
+ tile_name = tile_parameters["tile_name"]
51
+ decompressed_tile_path = os.path.join(self.hgt_directory, f"{tile_name}.hgt")
67
52
 
68
- Returns:
69
- dict: Tile parameters.
70
- """
71
- lat, lon = args
53
+ if not os.path.isfile(decompressed_tile_path):
54
+ compressed_tile_path = os.path.join(self.gz_directory, f"{tile_name}.hgt.gz")
55
+ if not self.get_or_download_tile(compressed_tile_path, **tile_parameters):
56
+ raise FileNotFoundError(f"Tile {tile_name} not found.")
72
57
 
73
- tile_latitude = math.floor(lat)
74
- tile_longitude = math.floor(lon)
58
+ with gzip.open(compressed_tile_path, "rb") as f_in:
59
+ with open(decompressed_tile_path, "wb") as f_out:
60
+ shutil.copyfileobj(f_in, f_out)
61
+ tiles.append(decompressed_tile_path)
75
62
 
76
- latitude_band = f"N{abs(tile_latitude):02d}" if lat >= 0 else f"S{abs(tile_latitude):02d}"
77
- if lon < 0:
78
- tile_name = f"{latitude_band}W{abs(tile_longitude):03d}"
79
- else:
80
- tile_name = f"{latitude_band}E{abs(tile_longitude):03d}"
63
+ return tiles
81
64
 
82
- self.logger.debug(
83
- "Detected tile name: %s for coordinates: lat %s, lon %s.", tile_name, lat, lon
84
- )
85
- return {"latitude_band": latitude_band, "tile_name": tile_name}
65
+ # region provider specific helpers
66
+ def download_tile(self, output_path: str, **kwargs) -> bool:
67
+ """Download a tile from the provider.
86
68
 
87
- def get_numpy(self) -> np.ndarray:
88
- """Get numpy array of the tile.
69
+ Arguments:
70
+ output_path (str): Path to save the downloaded tile.
89
71
 
90
72
  Returns:
91
- np.ndarray: Numpy array of the tile.
92
- """
93
- tile_parameters = self.get_tile_parameters(*self.coordinates)
94
- tile_name = tile_parameters["tile_name"]
95
- decompressed_tile_path = os.path.join(self.hgt_directory, f"{tile_name}.hgt")
96
-
97
- if not os.path.isfile(decompressed_tile_path):
98
- compressed_tile_path = os.path.join(self.gz_directory, f"{tile_name}.hgt.gz")
99
- if not self.get_or_download_tile(compressed_tile_path, **tile_parameters):
100
- raise FileNotFoundError(f"Tile {tile_name} not found.")
101
-
102
- with gzip.open(compressed_tile_path, "rb") as f_in:
103
- with open(decompressed_tile_path, "wb") as f_out:
104
- shutil.copyfileobj(f_in, f_out)
105
-
106
- data = self.extract_roi(decompressed_tile_path)
107
-
108
- self.data_info = {}
109
- self.add_numpy_params(data, "original")
110
-
111
- data = self.signed_to_unsigned(data)
112
- self.add_numpy_params(data, "grounded")
113
-
114
- original_deviation = int(self.data_info["original_deviation"])
115
- in_game_maximum_height = 65535 // 255
116
- if original_deviation > in_game_maximum_height:
117
- suggested_height_scale_multiplier = (
118
- original_deviation / in_game_maximum_height # type: ignore
119
- )
120
- suggested_height_scale_value = int(255 * suggested_height_scale_multiplier)
121
- else:
122
- suggested_height_scale_multiplier = 1
123
- suggested_height_scale_value = 255
124
-
125
- self.data_info["suggested_height_scale_multiplier"] = suggested_height_scale_multiplier
126
- self.data_info["suggested_height_scale_value"] = suggested_height_scale_value
127
-
128
- self.map.shared_settings.height_scale_multiplier = ( # type: ignore
129
- suggested_height_scale_multiplier
130
- )
131
- self.map.shared_settings.height_scale_value = suggested_height_scale_value # type: ignore
132
-
133
- if self.user_settings.easy_mode: # type: ignore
134
- try:
135
- data = self.normalize_dem(data)
136
- self.add_numpy_params(data, "normalized")
137
-
138
- normalized_deviation = self.data_info["normalized_deviation"]
139
- z_scaling_factor = normalized_deviation / original_deviation # type: ignore
140
- self.data_info["z_scaling_factor"] = z_scaling_factor
141
-
142
- self.map.shared_settings.mesh_z_scaling_factor = z_scaling_factor # type: ignore
143
- self.map.shared_settings.change_height_scale = True # type: ignore
144
-
145
- except Exception as e: # pylint: disable=W0718
146
- self.logger.error(
147
- "Failed to normalize DEM data. Error: %s. Using original data.", e
148
- )
149
-
150
- return data
151
-
152
- def add_numpy_params(
153
- self,
154
- data: np.ndarray,
155
- prefix: str,
156
- ) -> None:
157
- """Add numpy array parameters to the data_info dictionary.
158
-
159
- Arguments:
160
- data (np.ndarray): Numpy array of the tile.
161
- prefix (str): Prefix for the parameters.
73
+ bool: True if the tile was downloaded successfully, False otherwise.
162
74
  """
163
- self.data_info[f"{prefix}_minimum_height"] = int(data.min()) # type: ignore
164
- self.data_info[f"{prefix}_maximum_height"] = int(data.max()) # type: ignore
165
- self.data_info[f"{prefix}_deviation"] = int(data.max() - data.min()) # type: ignore
166
- self.data_info[f"{prefix}_unique_values"] = int(np.unique(data).size) # type: ignore
167
-
168
- def signed_to_unsigned(self, data: np.ndarray, add_one: bool = True) -> np.ndarray:
169
- """Convert signed 16-bit integer to unsigned 16-bit integer.
75
+ url = self.formatted_url(**kwargs)
76
+ response = requests.get(url, stream=True, timeout=10)
77
+ if response.status_code == 200:
78
+ with open(output_path, "wb") as file:
79
+ for chunk in response.iter_content(chunk_size=1024):
80
+ file.write(chunk)
81
+ return True
82
+ return False
83
+
84
+ def get_or_download_tile(self, output_path: str, **kwargs) -> str | None:
85
+ """Get or download a tile from the provider.
170
86
 
171
87
  Arguments:
172
- data (np.ndarray): DEM data from SRTM file after cropping.
88
+ output_path (str): Path to save the downloaded tile.
173
89
 
174
90
  Returns:
175
- np.ndarray: Unsigned DEM data.
91
+ str: Path to the downloaded tile or None if the tile not exists and was
92
+ not downloaded.
176
93
  """
177
- data = data - data.min()
178
- if add_one:
179
- data = data + 1
180
- return data.astype(np.uint16)
94
+ if not os.path.exists(output_path):
95
+ if not self.download_tile(output_path, **kwargs):
96
+ return None
97
+ return output_path
181
98
 
182
- def normalize_dem(self, data: np.ndarray) -> np.ndarray:
183
- """Normalize DEM data to 16-bit unsigned integer using max height from settings.
99
+ def get_tile_parameters(self, *args) -> dict[str, str]:
100
+ """Returns latitude band and tile name for SRTM tile from coordinates.
184
101
 
185
102
  Arguments:
186
- data (np.ndarray): DEM data from SRTM file after cropping.
103
+ lat (float): Latitude.
104
+ lon (float): Longitude.
187
105
 
188
106
  Returns:
189
- np.ndarray: Normalized DEM data.
107
+ dict: Tile parameters.
190
108
  """
191
- maximum_height = int(data.max())
192
- minimum_height = int(data.min())
193
- deviation = maximum_height - minimum_height
194
- self.logger.debug(
195
- "Maximum height: %s. Minimum height: %s. Deviation: %s.",
196
- maximum_height,
197
- minimum_height,
198
- deviation,
199
- )
200
- self.logger.debug("Number of unique values in original DEM data: %s.", np.unique(data).size)
109
+ lat, lon = args
201
110
 
202
- adjusted_maximum_height = maximum_height * 255
203
- adjusted_maximum_height = min(adjusted_maximum_height, 65535)
204
- scaling_factor = adjusted_maximum_height / maximum_height
205
- self.logger.debug(
206
- "Adjusted maximum height: %s. Scaling factor: %s.",
207
- adjusted_maximum_height,
208
- scaling_factor,
209
- )
111
+ tile_latitude = math.floor(lat)
112
+ tile_longitude = math.floor(lon)
210
113
 
211
- if self.user_settings.power_factor: # type: ignore
212
- power_factor = 1 + self.user_settings.power_factor / 10 # type: ignore
213
- self.logger.debug(
214
- "Applying power factor: %s to the DEM data.",
215
- power_factor,
216
- )
217
- data = np.power(data, power_factor).astype(np.uint16)
114
+ latitude_band = f"N{abs(tile_latitude):02d}" if lat >= 0 else f"S{abs(tile_latitude):02d}"
115
+ if lon < 0:
116
+ tile_name = f"{latitude_band}W{abs(tile_longitude):03d}"
117
+ else:
118
+ tile_name = f"{latitude_band}E{abs(tile_longitude):03d}"
218
119
 
219
- normalized_data = np.round(data * scaling_factor).astype(np.uint16)
220
120
  self.logger.debug(
221
- "Normalized data maximum height: %s. Minimum height: %s. Number of unique values: %s.",
222
- normalized_data.max(),
223
- normalized_data.min(),
224
- np.unique(normalized_data).size,
121
+ "Detected tile name: %s for coordinates: lat %s, lon %s.", tile_name, lat, lon
225
122
  )
226
- return normalized_data
123
+ return {"latitude_band": latitude_band, "tile_name": tile_name}
124
+ # endregion
@@ -5,12 +5,7 @@ from datetime import datetime
5
5
  from zipfile import ZipFile
6
6
 
7
7
  import numpy as np
8
- import rasterio
9
8
  import requests
10
- from rasterio.enums import Resampling
11
- from rasterio.merge import merge
12
- from rasterio.warp import calculate_default_transform, reproject
13
- from rasterio.windows import from_bounds
14
9
 
15
10
  from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
16
11
 
@@ -18,7 +13,6 @@ from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
18
13
  class USGSProviderSettings(DTMProviderSettings):
19
14
  """Settings for the USGS provider."""
20
15
 
21
- max_local_elevation: int = 255
22
16
  dataset: tuple | str = (
23
17
  'Digital Elevation Model (DEM) 1 meter',
24
18
  'Alaska IFSAR 5 meter DEM',
@@ -43,17 +37,18 @@ class USGSProvider(DTMProvider):
43
37
  _author = "[ZenJakey](https://github.com/ZenJakey)"
44
38
  _contributors = "[kbrandwijk](https://github.com/kbrandwijk)"
45
39
  _is_community = True
46
- _instructions = (
47
- "ℹ️ Set the max local elevation to approx the local max elevation for your area in"
48
- " meters. This will allow you to use heightScale 255 in GE with minimal tweaking."
49
- " Setting this value too low can cause a flat map!"
50
- )
40
+ _instructions = None
51
41
 
52
42
  _url = (
53
43
  "https://tnmaccess.nationalmap.gov/api/v1/products?prodFormats=GeoTIFF,IMG"
54
44
 
55
45
  )
56
46
 
47
+ def download_tiles(self):
48
+ download_urls = self.get_download_urls()
49
+ all_tif_files = self.download_tif_files(download_urls)
50
+ return all_tif_files
51
+
57
52
  def __init__(self, *args, **kwargs):
58
53
  super().__init__(*args, **kwargs)
59
54
  timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
@@ -138,214 +133,3 @@ class USGSProvider(DTMProvider):
138
133
  tif_files.append(file_path)
139
134
 
140
135
  return tif_files
141
-
142
- def merge_geotiff(self, input_files: list[str], output_file: str) -> None:
143
- """Merge multiple GeoTIFF files into a single GeoTIFF file.
144
-
145
- Arguments:
146
- input_files (list): List of input GeoTIFF files to merge.
147
- output_file (str): Path to save the merged GeoTIFF file.
148
- """
149
- # Open all input GeoTIFF files as datasets
150
- self.logger.debug("Merging tiff files...")
151
- datasets = [rasterio.open(file) for file in input_files]
152
-
153
- # Merge datasets
154
- mosaic, out_transform = merge(datasets, nodata=0)
155
-
156
- # Get metadata from the first file and update it for the output
157
- out_meta = datasets[0].meta.copy()
158
- out_meta.update(
159
- {
160
- "driver": "GTiff",
161
- "height": mosaic.shape[1],
162
- "width": mosaic.shape[2],
163
- "transform": out_transform,
164
- "count": mosaic.shape[0], # Number of bands
165
- }
166
- )
167
-
168
- # Write merged GeoTIFF to the output file
169
- with rasterio.open(output_file, "w", **out_meta) as dest:
170
- dest.write(mosaic)
171
-
172
- self.logger.debug("GeoTIFF images merged successfully into %s", output_file)
173
-
174
- def reproject_geotiff(self, input_tiff: str, output_tiff: str, target_crs: str) -> None:
175
- """Reproject a GeoTIFF file to a new coordinate reference system (CRS).
176
-
177
- Arguments:
178
- input_tiff (str): Path to the input GeoTIFF file.
179
- output_tiff (str): Path to save the reprojected GeoTIFF file.
180
- target_crs (str): Target CRS (e.g., EPSG:4326 for CRS:84).
181
- """
182
- # Open the source GeoTIFF
183
- self.logger.debug("Reprojecting GeoTIFF to %s CRS...", target_crs)
184
- with rasterio.open(input_tiff) as src:
185
- # Get the transform, width, and height of the target CRS
186
- transform, width, height = calculate_default_transform(
187
- src.crs, target_crs, src.width, src.height, *src.bounds
188
- )
189
-
190
- # Update the metadata for the target GeoTIFF
191
- kwargs = src.meta.copy()
192
- kwargs.update(
193
- {"crs": target_crs, "transform": transform, "width": width, "height": height}
194
- )
195
-
196
- # Open the destination GeoTIFF file and reproject
197
- with rasterio.open(output_tiff, "w", **kwargs) as dst:
198
- for i in range(1, src.count + 1): # Iterate over all raster bands
199
- reproject(
200
- source=rasterio.band(src, i),
201
- destination=rasterio.band(dst, i),
202
- src_transform=src.transform,
203
- src_crs=src.crs,
204
- dst_transform=transform,
205
- dst_crs=target_crs,
206
- resampling=Resampling.nearest, # Choose resampling method
207
- )
208
- self.logger.debug("Reprojected GeoTIFF saved to %s", output_tiff)
209
-
210
- def extract_roi(self, input_tiff: str) -> np.ndarray: # pylint: disable=W0237
211
- """
212
- Crop a GeoTIFF based on given geographic bounding box and save to a new file.
213
-
214
- Arguments:
215
- input_tiff (str): Path to the input GeoTIFF file.
216
-
217
- Returns:
218
- np.ndarray: Numpy array of the cropped GeoTIFF.
219
- """
220
- self.logger.debug("Extracting ROI...")
221
- # Open the input GeoTIFF
222
- with rasterio.open(input_tiff) as src:
223
-
224
- # Create a rasterio window from the bounding box
225
- (north, south, east, west) = self.get_bbox()
226
- window = from_bounds(west, south, east, north, transform=src.transform)
227
-
228
- data = src.read(1, window=window)
229
- self.logger.debug("Extracted ROI")
230
- return data
231
-
232
- # pylint: disable=R0914, R0917, R0913
233
- def convert_geotiff_to_geotiff(
234
- self,
235
- input_tiff: str,
236
- output_tiff: str,
237
- min_height: float,
238
- max_height: float,
239
- target_crs: str,
240
- ) -> None:
241
- """
242
- Convert a GeoTIFF to a scaled GeoTIFF with UInt16 values using a specific coordinate
243
- system and output size.
244
-
245
- Arguments:
246
- input_tiff (str): Path to the input GeoTIFF file.
247
- output_tiff (str): Path to save the output GeoTIFF file.
248
- min_height (float): Minimum terrain height (input range).
249
- max_height (float): Maximum terrain height (input range).
250
- target_crs (str): Target CRS (e.g., EPSG:4326 for CRS:84).
251
- """
252
- # Open the input GeoTIFF file
253
- self.logger.debug("Converting to uint16")
254
- with rasterio.open(input_tiff) as src:
255
- # Ensure the input CRS matches the target CRS (reprojection may be required)
256
- if str(src.crs) != str(target_crs):
257
- raise ValueError(
258
- f"The GeoTIFF CRS is {src.crs}, but the target CRS is {target_crs}. "
259
- "Reprojection may be required."
260
- )
261
-
262
- # Read the data from the first band
263
- data = src.read(1) # Assuming the input GeoTIFF has only a single band
264
-
265
- # Identify the input file's NoData value
266
- input_nodata = src.nodata
267
- if input_nodata is None:
268
- input_nodata = -999999.0 # Default fallback if no NoData value is defined
269
- nodata_value = 0
270
- # Replace NoData values (e.g., -999999.0) with the new NoData value
271
- # (e.g., 65535 for UInt16)
272
- data[data == input_nodata] = nodata_value
273
-
274
- # Scale the data to the 0–65535 range (UInt16), avoiding NoData areas
275
- scaled_data = np.clip(
276
- (data - min_height) * (65535 / (max_height - min_height)), 0, 65535
277
- ).astype(np.uint16)
278
- scaled_data[data == nodata_value] = (
279
- nodata_value # Preserve NoData value in the scaled array
280
- )
281
-
282
- # Compute the proper transform to ensure consistency
283
- # Get the original transform, width, and height
284
- transform = src.transform
285
- width = src.width
286
- height = src.height
287
- left, bottom, right, top = src.bounds
288
-
289
- # Adjust the transform matrix to make sure bounds and transform align correctly
290
- transform = rasterio.transform.from_bounds(left, bottom, right, top, width, height)
291
-
292
- # Prepare metadata for the output GeoTIFF
293
- metadata = src.meta.copy()
294
- metadata.update(
295
- {
296
- "dtype": rasterio.uint16, # Update dtype for uint16
297
- "crs": target_crs, # Update CRS if needed
298
- "nodata": nodata_value, # Set the new NoData value
299
- "transform": transform, # Use the updated, consistent transform
300
- }
301
- )
302
-
303
- # Write the scaled data to the output GeoTIFF
304
- with rasterio.open(output_tiff, "w", **metadata) as dst:
305
- dst.write(scaled_data, 1) # Write the first band
306
-
307
- self.logger.debug(
308
- "GeoTIFF successfully converted and saved to %s, with nodata value: %s.",
309
- output_tiff,
310
- nodata_value,
311
- )
312
-
313
- def generate_data(self) -> np.ndarray:
314
- """Generate data from the USGS 1m provider.
315
-
316
- Returns:
317
- np.ndarray: Numpy array of the data.
318
- """
319
- download_urls = self.get_download_urls()
320
- all_tif_files = self.download_tif_files(download_urls)
321
- self.merge_geotiff(all_tif_files, os.path.join(self.output_path, "merged.tif"))
322
- self.reproject_geotiff(
323
- os.path.join(self.output_path, "merged.tif"),
324
- os.path.join(self.output_path, "reprojected.tif"),
325
- "EPSG:4326",
326
- )
327
- self.convert_geotiff_to_geotiff(
328
- os.path.join(self.output_path, "reprojected.tif"),
329
- os.path.join(self.output_path, "translated.tif"),
330
- min_height=0,
331
- max_height=self.user_settings.max_local_elevation, # type: ignore
332
- target_crs="EPSG:4326",
333
- )
334
- return self.extract_roi(os.path.join(self.output_path, "translated.tif"))
335
-
336
- def get_numpy(self) -> np.ndarray:
337
- """Get numpy array of the tile.
338
-
339
- Returns:
340
- np.ndarray: Numpy array of the tile.
341
- """
342
- if not self.user_settings:
343
- raise ValueError("user_settings is 'none'")
344
- if self.user_settings.max_local_elevation <= 0: # type: ignore
345
- raise ValueError(
346
- "Entered 'max_local_elevation' value is unable to be used. "
347
- "Use a value greater than 0."
348
- )
349
- if not self._data:
350
- self._data = self.generate_data()
351
- return self._data
@@ -0,0 +1,67 @@
1
+ """This module contains functions to work with custom OSM files."""
2
+
3
+ import json
4
+ from xml.etree import ElementTree as ET
5
+
6
+ import osmnx as ox
7
+ from osmnx._errors import InsufficientResponseError
8
+
9
+ from maps4fs.generator.game import FS25
10
+
11
+
12
+ def check_osm_file(file_path: str) -> bool:
13
+ """Tries to read the OSM file using OSMnx and returns True if the file is valid,
14
+ False otherwise.
15
+
16
+ Arguments:
17
+ file_path (str): Path to the OSM file.
18
+
19
+ Returns:
20
+ bool: True if the file is valid, False otherwise.
21
+ """
22
+ with open(FS25().texture_schema, encoding="utf-8") as f:
23
+ schema = json.load(f)
24
+
25
+ tags = []
26
+ for element in schema:
27
+ element_tags = element.get("tags")
28
+ if element_tags:
29
+ tags.append(element_tags)
30
+
31
+ for tag in tags:
32
+ try:
33
+ ox.features_from_xml(file_path, tags=tag)
34
+ except InsufficientResponseError:
35
+ continue
36
+ except Exception: # pylint: disable=W0718
37
+ return False
38
+ return True
39
+
40
+
41
+ def fix_osm_file(input_file_path: str, output_file_path: str) -> tuple[bool, int]:
42
+ """Fixes the OSM file by removing all the <relation> nodes and all the nodes with
43
+ action='delete'.
44
+
45
+ Arguments:
46
+ input_file_path (str): Path to the input OSM file.
47
+ output_file_path (str): Path to the output OSM file.
48
+
49
+ Returns:
50
+ tuple[bool, int]: A tuple containing the result of the check_osm_file function
51
+ and the number of fixed errors.
52
+ """
53
+ broken_entries = ["relation", ".//*[@action='delete']"]
54
+
55
+ tree = ET.parse(input_file_path)
56
+ root = tree.getroot()
57
+
58
+ fixed_errors = 0
59
+ for entry in broken_entries:
60
+ for element in root.findall(entry):
61
+ root.remove(element)
62
+ fixed_errors += 1
63
+
64
+ tree.write(output_file_path)
65
+ result = check_osm_file(output_file_path)
66
+
67
+ return result, fixed_errors
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: maps4fs
3
- Version: 1.7.2
3
+ Version: 1.7.4
4
4
  Summary: Generate map templates for Farming Simulator from real places.
5
5
  Author-email: iwatkot <iwatkot@gmail.com>
6
6
  License: MIT License
@@ -36,7 +36,6 @@ Requires-Dist: pydantic
36
36
  <a href="#How-To-Run">How-To-Run</a><br>
37
37
  <a href="docs/FAQ.md">FAQ</a> •
38
38
  <a href="docs/map_structure.md">Map Structure</a> •
39
- <a href="docs/tips_and_hints.md">Tips and Hints</a> •
40
39
  <a href="#Modder-Toolbox">Modder Toolbox</a><br>
41
40
  <a href="#Supported-objects">Supported objects</a> •
42
41
  <a href="#Generation-info">Generation info</a> •
@@ -293,6 +292,9 @@ Tools are divided into categories, which are listed below.
293
292
  - **Texture Schema Editor** - allows you to view all the supported textures and edit their parameters, such as priority, OSM tags and so on. After editing, you should click the Show updated schema button and copy the JSON schema to the clipboard. Then you can use it in the Expert settings to generate the map with the updated textures.
294
293
 
295
294
  #### For Textures and DEM
295
+
296
+ - **Fix custom OSM file** - this tool fixes the most common errors in the custom OSM file, but it can not guarantee that the file will be fixed completely if some non-common errors are present.
297
+
296
298
  - **GeoTIFF windowing** - allows you to upload your GeoTIFF file and select the region of interest to extract it from the image. It's useful when you have high-resolution DEM data and want to create a height map using it.
297
299
 
298
300
  #### For Background terrain
@@ -1,4 +1,4 @@
1
- maps4fs/__init__.py,sha256=rUpfzsaHxFEf4wS1f1TtmIIcTs8Ub-X_KE9zdiQVt5A,559
1
+ maps4fs/__init__.py,sha256=TXqX7Ks_Kqt2fiXRt5zdVSLUHxP4cT_p7jgutYFdbo8,630
2
2
  maps4fs/logger.py,sha256=B-NEYpMjPAAqlV4VpfTi6nbBFnEABVtQOaYe6nMpidg,1489
3
3
  maps4fs/generator/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
4
4
  maps4fs/generator/background.py,sha256=tV4UXvtkNN-OSvv6ujp4jFWRU1xGBgEvSakVGZ1H4nc,24877
@@ -14,14 +14,16 @@ maps4fs/generator/satellite.py,sha256=_7RcuNmR1mjxEJWMDsjnzKUIqWxnGUn50XtjB7HmSP
14
14
  maps4fs/generator/settings.py,sha256=9vbXISQrE-aDY7ATpvZ7LVJMqjfwa3-gNl-huI8XLO0,5666
15
15
  maps4fs/generator/texture.py,sha256=P4AJjedG98SFxrw4hBenw7_OgtkcI0TpE63fEffJ2eE,33761
16
16
  maps4fs/generator/dtm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- maps4fs/generator/dtm/dtm.py,sha256=nCQKQygARLxaz4HkREQQd0Yb03oKOf1Iav5_VoZsFWI,9819
18
- maps4fs/generator/dtm/srtm.py,sha256=2-pX6bWrJX6gr8IM7ueX6mm_PW7_UQ58MtdzDHae2OQ,9030
19
- maps4fs/generator/dtm/usgs.py,sha256=hwVjoSNTNRU6hwnfwJ2d3rOdtOjadCmx2QESA2REn6s,14493
17
+ maps4fs/generator/dtm/dtm.py,sha256=T2h7eP5kQEWTGllI8ZxcGCDW4czZSoPBgOUS8a7_Ym8,19105
18
+ maps4fs/generator/dtm/nrw.py,sha256=lJYZBZB4n5egUlX2iA7AhLmMIRKB7i_LVxCTohWsSmw,4612
19
+ maps4fs/generator/dtm/srtm.py,sha256=RsvVa7ErajPwXoetG7mO_rldji9GR97HFaazH-PkdHw,4399
20
+ maps4fs/generator/dtm/usgs.py,sha256=fWFR_kO_uLVjAJAL43YFvBaHwsXWI-00jMlp23Ue7Wo,5450
20
21
  maps4fs/toolbox/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
21
22
  maps4fs/toolbox/background.py,sha256=9BXWNqs_n3HgqDiPztWylgYk_QM4YgBpe6_ZNQAWtSc,2154
23
+ maps4fs/toolbox/custom_osm.py,sha256=X6ZlPqiOhNjkmdD_qVroIfdOl9Rb90cDwVSLDVYgx80,1892
22
24
  maps4fs/toolbox/dem.py,sha256=z9IPFNmYbjiigb3t02ZenI3Mo8odd19c5MZbjDEovTo,3525
23
- maps4fs-1.7.2.dist-info/LICENSE.md,sha256=pTKD_oUexcn-yccFCTrMeLkZy0ifLRa-VNcDLqLZaIw,10749
24
- maps4fs-1.7.2.dist-info/METADATA,sha256=t_t_Ybmh8QDgytrnMqmUBTtWGVhnyn6_rPNpTDYq2k4,40298
25
- maps4fs-1.7.2.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
26
- maps4fs-1.7.2.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
27
- maps4fs-1.7.2.dist-info/RECORD,,
25
+ maps4fs-1.7.4.dist-info/LICENSE.md,sha256=pTKD_oUexcn-yccFCTrMeLkZy0ifLRa-VNcDLqLZaIw,10749
26
+ maps4fs-1.7.4.dist-info/METADATA,sha256=RqMBGBQBs4zhyn8fnpnNwXl6Fm_zhPBaNuxo1-TVQUg,40436
27
+ maps4fs-1.7.4.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
28
+ maps4fs-1.7.4.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
29
+ maps4fs-1.7.4.dist-info/RECORD,,