diff --git a/maps4fs/generator/background.py b/maps4fs/generator/background.py index ff7e0d83..38f05867 100644 --- a/maps4fs/generator/background.py +++ b/maps4fs/generator/background.py @@ -148,6 +148,9 @@ def info_sequence(self) -> dict[str, str | float | int]: "east": east, "west": west, } + + dem_info_sequence = self.dem.info_sequence() + data["DEM"] = dem_info_sequence return data # type: ignore def qgis_sequence(self) -> None: @@ -294,7 +297,7 @@ def plane_from_np( mesh.apply_transform(rotation_matrix_z) # if not include_zeros: - z_scaling_factor = 1 / self.map.dem_settings.multiplier + z_scaling_factor = self.get_z_scaling_factor() self.logger.debug("Z scaling factor: %s", z_scaling_factor) mesh.apply_scale([1 / resize_factor, 1 / resize_factor, z_scaling_factor]) @@ -309,6 +312,29 @@ def plane_from_np( mesh.apply_scale([0.5, 0.5, 0.5]) self.mesh_to_stl(mesh) + def get_z_scaling_factor(self) -> float: + """Calculates the scaling factor for the Z axis based on the map settings. + + Returns: + float -- The scaling factor for the Z axis. + """ + + scaling_factor = 1 / self.map.dem_settings.multiplier + self.logger.debug("Z scaling factor including DEM multiplier: %s", scaling_factor) + + if self.map.shared_settings.height_scale_multiplier: + scaling_factor *= self.map.shared_settings.height_scale_multiplier + self.logger.debug( + "Z scaling factor including height scale multiplier: %s", scaling_factor + ) + if self.map.shared_settings.mesh_z_scaling_factor: + scaling_factor *= 1 / self.map.shared_settings.mesh_z_scaling_factor + self.logger.debug( + "Z scaling factor including mesh z scaling factor: %s", scaling_factor + ) + + return scaling_factor + def mesh_to_stl(self, mesh: trimesh.Trimesh) -> None: """Converts the mesh to an STL file and saves it in the previews directory. Uses powerful simplification to reduce the size of the file since it will be used diff --git a/maps4fs/generator/component.py b/maps4fs/generator/component.py index cb31e72b..0277f1b2 100644 --- a/maps4fs/generator/component.py +++ b/maps4fs/generator/component.py @@ -58,7 +58,7 @@ def __init__( self.logger = logger self.kwargs = kwargs - self.logger.info( + self.logger.debug( "Component %s initialized. Map size: %s, map rotated size: %s", # type: ignore self.__class__.__name__, self.map_size, diff --git a/maps4fs/generator/dem.py b/maps4fs/generator/dem.py index ccd8fc08..33a08ea6 100644 --- a/maps4fs/generator/dem.py +++ b/maps4fs/generator/dem.py @@ -1,6 +1,7 @@ """This module contains DEM class for processing Digital Elevation Model data.""" import os +from typing import Any import cv2 import numpy as np @@ -63,6 +64,7 @@ def preprocess(self) -> None: size=self.map_rotated_size, directory=self.temp_dir, logger=self.logger, + map=self.map, ) @property @@ -280,3 +282,16 @@ def previews(self) -> list: list: Empty list. """ return [] + + def info_sequence(self) -> dict[Any, Any] | None: # type: ignore + """Returns the information sequence for the component. Must be implemented in the child + class. If the component does not have an information sequence, an empty dictionary must be + returned. + + Returns: + dict[Any, Any]: The information sequence for the component. + """ + provider_info_sequence = self.dtm_provider.info_sequence() + if provider_info_sequence is None: + return {} + return provider_info_sequence diff --git a/maps4fs/generator/dtm/dtm.py b/maps4fs/generator/dtm/dtm.py index 8a0d57a9..a9475728 100644 --- a/maps4fs/generator/dtm/dtm.py +++ b/maps4fs/generator/dtm/dtm.py @@ -5,7 +5,7 @@ from __future__ import annotations import os -from typing import Type +from typing import TYPE_CHECKING, Type import numpy as np import osmnx as ox # type: ignore @@ -15,6 +15,9 @@ from maps4fs.logger import Logger +if TYPE_CHECKING: + from maps4fs.generator.map import Map + class DTMProviderSettings(BaseModel): """Base class for DTM provider settings models.""" @@ -45,6 +48,7 @@ def __init__( size: int, directory: str, logger: Logger, + map: Map | None = None, # pylint: disable=W0622 ): self._coordinates = coordinates self._user_settings = user_settings @@ -56,6 +60,27 @@ def __init__( os.makedirs(self._tile_directory, exist_ok=True) self.logger = logger + self.map = map + + self._data_info: dict[str, int | str | float] | None = None + + @property + def data_info(self) -> dict[str, int | str | float] | None: + """Information about the DTM data. + + Returns: + dict: Information about the DTM data. + """ + return self._data_info + + @data_info.setter + def data_info(self, value: dict[str, int | str | float] | None) -> None: + """Set information about the DTM data. + + Arguments: + value (dict): Information about the DTM data. + """ + self._data_info = value @property def coordinates(self) -> tuple[float, float]: @@ -260,3 +285,13 @@ def extract_roi(self, tile_path: str) -> np.ndarray: raise ValueError("No data in the tile.") return data + + def info_sequence(self) -> dict[str, int | str | float] | None: + """Returns the information sequence for the component. Must be implemented in the child + class. If the component does not have an information sequence, an empty dictionary must be + returned. + + Returns: + dict[str, int | str | float] | None: Information sequence for the component. + """ + return self.data_info diff --git a/maps4fs/generator/dtm/srtm.py b/maps4fs/generator/dtm/srtm.py index f13bbb28..5ac286d9 100644 --- a/maps4fs/generator/dtm/srtm.py +++ b/maps4fs/generator/dtm/srtm.py @@ -1,5 +1,7 @@ """This module contains provider of Shuttle Radar Topography Mission (SRTM) 30m data.""" +# Author: https://github.com/iwatkot + import gzip import math import os @@ -7,7 +9,14 @@ import numpy as np -from maps4fs.generator.dtm.dtm import DTMProvider +from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings + + +class SRTM30ProviderSettings(DTMProviderSettings): + """Settings for SRTM 30m provider.""" + + easy_mode: bool = True + power_factor: int = 0 class SRTM30Provider(DTMProvider): @@ -24,19 +33,30 @@ class SRTM30Provider(DTMProvider): _author = "[iwatkot](https://github.com/iwatkot)" _instructions = ( - "ℹ️ If you're a rookie in the Giants Editor check the **Apply the default multiplier** " - "checkbox. Otherwise, you can change the multiplier value in the DEM Settings. " - "If you will not apply the default multiplier and not change the value in the DEM " - "Settings, you'll have the DEM image with the original values as on Earth, which can " - "not be seen by eye and will lead to completely flat terrain. " + "ℹ️ If you don't know how to work with DEM data, it is recommended to use the " + "**Easy mode** option. It will automatically change the values in the image, so the " + "terrain will be visible in the Giants Editor. If you're an experienced modder, it's " + "recommended to disable this option and work with the DEM data in a usual way. \n" + "ℹ️ If the terrain height difference in the real world is bigger than 255 meters, " + "the [Height scale](https://github.com/iwatkot/maps4fs/blob/main/docs/dem.md#height-scale)" + " parameter in the **map.i3d** file will be changed automatically. \n" + "⚡ If the **Easy mode** option is disabled, you will probably get completely flat " + "terrain, unless you adjust the DEM Multiplier Setting or the Height scale parameter in " + "the Giants Editor. \n" + "💡 You can use the **Power factor** setting to make the difference between heights " + "bigger. Be extremely careful with this setting, and use only low values, otherwise your " + "terrain may be completely broken. \n" ) + _settings = SRTM30ProviderSettings + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.hgt_directory = os.path.join(self._tile_directory, "hgt") self.gz_directory = os.path.join(self._tile_directory, "gz") os.makedirs(self.hgt_directory, exist_ok=True) os.makedirs(self.gz_directory, exist_ok=True) + self.data_info: dict[str, int | str | float] | None = None # type: ignore def get_tile_parameters(self, *args, **kwargs) -> dict[str, str]: """Returns latitude band and tile name for SRTM tile from coordinates. @@ -83,4 +103,124 @@ def get_numpy(self) -> np.ndarray: with open(decompressed_tile_path, "wb") as f_out: shutil.copyfileobj(f_in, f_out) - return self.extract_roi(decompressed_tile_path) + data = self.extract_roi(decompressed_tile_path) + + self.data_info = {} + self.add_numpy_params(data, "original") + + data = self.signed_to_unsigned(data) + self.add_numpy_params(data, "grounded") + + original_deviation = int(self.data_info["original_deviation"]) + in_game_maximum_height = 65535 // 255 + if original_deviation > in_game_maximum_height: + suggested_height_scale_multiplier = ( + original_deviation / in_game_maximum_height # type: ignore + ) + suggested_height_scale_value = int(255 * suggested_height_scale_multiplier) + else: + suggested_height_scale_multiplier = 1 + suggested_height_scale_value = 255 + + self.data_info["suggested_height_scale_multiplier"] = suggested_height_scale_multiplier + self.data_info["suggested_height_scale_value"] = suggested_height_scale_value + + self.map.shared_settings.height_scale_multiplier = ( # type: ignore + suggested_height_scale_multiplier + ) + self.map.shared_settings.height_scale_value = suggested_height_scale_value # type: ignore + + if self.user_settings.easy_mode: # type: ignore + try: + data = self.normalize_dem(data) + self.add_numpy_params(data, "normalized") + + normalized_deviation = self.data_info["normalized_deviation"] + z_scaling_factor = normalized_deviation / original_deviation # type: ignore + self.data_info["z_scaling_factor"] = z_scaling_factor + + self.map.shared_settings.mesh_z_scaling_factor = z_scaling_factor # type: ignore + self.map.shared_settings.change_height_scale = True # type: ignore + + except Exception as e: # pylint: disable=W0718 + self.logger.error( + "Failed to normalize DEM data. Error: %s. Using original data.", e + ) + + return data + + def add_numpy_params( + self, + data: np.ndarray, + prefix: str, + ) -> None: + """Add numpy array parameters to the data_info dictionary. + + Arguments: + data (np.ndarray): Numpy array of the tile. + prefix (str): Prefix for the parameters. + """ + self.data_info[f"{prefix}_minimum_height"] = int(data.min()) # type: ignore + self.data_info[f"{prefix}_maximum_height"] = int(data.max()) # type: ignore + self.data_info[f"{prefix}_deviation"] = int(data.max() - data.min()) # type: ignore + self.data_info[f"{prefix}_unique_values"] = int(np.unique(data).size) # type: ignore + + def signed_to_unsigned(self, data: np.ndarray, add_one: bool = True) -> np.ndarray: + """Convert signed 16-bit integer to unsigned 16-bit integer. + + Arguments: + data (np.ndarray): DEM data from SRTM file after cropping. + + Returns: + np.ndarray: Unsigned DEM data. + """ + data = data - data.min() + if add_one: + data = data + 1 + return data.astype(np.uint16) + + def normalize_dem(self, data: np.ndarray) -> np.ndarray: + """Normalize DEM data to 16-bit unsigned integer using max height from settings. + + Arguments: + data (np.ndarray): DEM data from SRTM file after cropping. + + Returns: + np.ndarray: Normalized DEM data. + """ + maximum_height = int(data.max()) + minimum_height = int(data.min()) + deviation = maximum_height - minimum_height + self.logger.debug( + "Maximum height: %s. Minimum height: %s. Deviation: %s.", + maximum_height, + minimum_height, + deviation, + ) + self.logger.debug("Number of unique values in original DEM data: %s.", np.unique(data).size) + + adjusted_maximum_height = maximum_height * 255 + adjusted_maximum_height = min(adjusted_maximum_height, 65535) + scaling_factor = adjusted_maximum_height / maximum_height + self.logger.debug( + "Adjusted maximum height: %s. Scaling factor: %s.", + adjusted_maximum_height, + scaling_factor, + ) + + if self.user_settings.power_factor: # type: ignore + power_factor = 1 + self.user_settings.power_factor / 10 # type: ignore + self.logger.debug( + "Applying power factor: %s to the DEM data.", + power_factor, + ) + data = np.power(data, power_factor).astype(np.uint16) + + normalized_data = np.round(data * scaling_factor).astype(np.uint16) + self.logger.debug( + "Normalized data maximum height: %s. Minimum height: %s. Number of unique values: %s.", + normalized_data.max(), + normalized_data.min(), + np.unique(normalized_data).size, + ) + return normalized_data diff --git a/maps4fs/generator/i3d.py b/maps4fs/generator/i3d.py index 756d44a5..1214008d 100644 --- a/maps4fs/generator/i3d.py +++ b/maps4fs/generator/i3d.py @@ -14,7 +14,6 @@ from maps4fs.generator.component import Component from maps4fs.generator.texture import Texture -DEFAULT_HEIGHT_SCALE = 2000 DISPLACEMENT_LAYER_SIZE_FOR_BIG_MAPS = 32768 DEFAULT_MAX_LOD_DISTANCE = 10000 DEFAULT_MAX_LOD_OCCLUDER_DISTANCE = 10000 @@ -81,6 +80,20 @@ def _update_i3d_file(self) -> None: root = tree.getroot() for map_elem in root.iter("Scene"): + for terrain_elem in map_elem.iter("TerrainTransformGroup"): + if self.map.shared_settings.change_height_scale: + suggested_height_scale = self.map.shared_settings.height_scale_value + if suggested_height_scale is not None and suggested_height_scale > 255: + new_height_scale = int( + self.map.shared_settings.height_scale_value # type: ignore + ) + terrain_elem.set("heightScale", str(new_height_scale)) + self.logger.info( + "heightScale attribute set to %s in TerrainTransformGroup element.", + new_height_scale, + ) + + self.logger.debug("TerrainTransformGroup element updated in I3D file.") sun_elem = map_elem.find(".//Light[@name='sun']") if sun_elem is not None: @@ -97,10 +110,6 @@ def _update_i3d_file(self) -> None: ) if self.map_size > 4096: - terrain_elem = root.find(".//TerrainTransformGroup") - if terrain_elem is None: - self.logger.warning("TerrainTransformGroup element not found in I3D file.") - return displacement_layer = terrain_elem.find(".//DisplacementLayer") # pylint: disable=W0631 if displacement_layer is not None: diff --git a/maps4fs/generator/map.py b/maps4fs/generator/map.py index 00ee4ee8..4f5e3b03 100644 --- a/maps4fs/generator/map.py +++ b/maps4fs/generator/map.py @@ -16,6 +16,7 @@ GRLESettings, I3DSettings, SatelliteSettings, + SharedSettings, SplineSettings, TextureSettings, ) @@ -123,6 +124,8 @@ def __init__( # pylint: disable=R0917, R0915 with open(save_path, "w", encoding="utf-8") as file: json.dump(settings_json, file, indent=4) + self.shared_settings = SharedSettings() + self.texture_custom_schema = kwargs.get("texture_custom_schema", None) if self.texture_custom_schema: save_path = os.path.join(self.map_directory, "texture_custom_schema.json") @@ -245,7 +248,7 @@ def pack(self, archive_path: str, remove_source: bool = True) -> str: str: Path to the archive. """ archive_path = shutil.make_archive(archive_path, "zip", self.map_directory) - self.logger.info("Map packed to %s.zip", archive_path) + self.logger.debug("Map packed to %s.zip", archive_path) if remove_source: try: shutil.rmtree(self.map_directory) diff --git a/maps4fs/generator/satellite.py b/maps4fs/generator/satellite.py index 923eacd6..bfb90280 100644 --- a/maps4fs/generator/satellite.py +++ b/maps4fs/generator/satellite.py @@ -34,7 +34,7 @@ def process(self) -> None: """Downloads the satellite images for the map.""" self.image_paths = [] # pylint: disable=W0201 if not self.map.satellite_settings.download_images: - self.logger.info("Satellite images download is disabled.") + self.logger.debug("Satellite images download is disabled.") return margin = self.map.satellite_settings.satellite_margin diff --git a/maps4fs/generator/settings.py b/maps4fs/generator/settings.py index 86a34873..9b53c2ba 100644 --- a/maps4fs/generator/settings.py +++ b/maps4fs/generator/settings.py @@ -4,7 +4,20 @@ from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict + + +class SharedSettings(BaseModel): + """Represents the shared settings for all components.""" + + mesh_z_scaling_factor: float | None = None + height_scale_multiplier: float | None = None + height_scale_value: float | None = None + change_height_scale: bool = False + + model_config = ConfigDict( + frozen=False, + ) class SettingsModel(BaseModel): diff --git a/maps4fs/generator/texture.py b/maps4fs/generator/texture.py index 04bab5d8..f5cad16a 100644 --- a/maps4fs/generator/texture.py +++ b/maps4fs/generator/texture.py @@ -187,7 +187,7 @@ def preprocess(self) -> None: custom_schema = self.kwargs.get("texture_custom_schema") if custom_schema: layers_schema = custom_schema # type: ignore - self.logger.info("Custom schema loaded with %s layers.", len(layers_schema)) + self.logger.debug("Custom schema loaded with %s layers.", len(layers_schema)) else: if not os.path.isfile(self.game.texture_schema): raise FileNotFoundError( @@ -202,15 +202,13 @@ def preprocess(self) -> None: try: self.layers = [self.Layer.from_json(layer) for layer in layers_schema] # type: ignore - self.logger.info("Loaded %s layers.", len(self.layers)) + self.logger.debug("Loaded %s layers.", len(self.layers)) except Exception as e: # pylint: disable=W0703 raise ValueError(f"Error loading texture layers: {e}") from e base_layer = self.get_base_layer() if base_layer: self.logger.debug("Base layer found: %s.", base_layer.name) - else: - self.logger.warning("No base layer found.") self._weights_dir = self.game.weights_dir_path(self.map_directory) self.logger.debug("Weights directory: %s.", self._weights_dir) @@ -595,7 +593,7 @@ def _to_polygon( geometry_type = geometry.geom_type converter = self._converters(geometry_type) if not converter: - self.logger.warning("Geometry type %s not supported.", geometry_type) + self.logger.debug("Geometry type %s not supported.", geometry_type) return None return converter(geometry, width) diff --git a/tests/test_generator.py b/tests/test_generator.py index 416d2472..7afc38b0 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -7,6 +7,7 @@ import cv2 from maps4fs import DTMProvider, Map +from maps4fs.generator.dtm.srtm import SRTM30ProviderSettings from maps4fs.generator.game import Game from maps4fs.generator.settings import ( BackgroundSettings, @@ -35,6 +36,8 @@ dtm_provider_code = "srtm30" dtm_provider = DTMProvider.get_provider_by_code(dtm_provider_code) +dtm_settings = SRTM30ProviderSettings() + def get_random_size() -> tuple[int, int]: """Return random size. @@ -85,7 +88,7 @@ def test_map(): map = Map( game=game, dtm_provider=dtm_provider, - dtm_provider_settings=None, + dtm_provider_settings=dtm_settings, coordinates=coordinates, size=height, rotation=0, @@ -147,7 +150,7 @@ def test_map_preview(): map = Map( game=game, dtm_provider=dtm_provider, - dtm_provider_settings=None, + dtm_provider_settings=dtm_settings, coordinates=case, size=height, rotation=0, @@ -184,7 +187,7 @@ def test_map_pack(): map = Map( game=game, dtm_provider=dtm_provider, - dtm_provider_settings=None, + dtm_provider_settings=dtm_settings, coordinates=case, size=height, rotation=30, diff --git a/webui/generator.py b/webui/generator.py index ecd8a9ee..979edef0 100644 --- a/webui/generator.py +++ b/webui/generator.py @@ -106,7 +106,7 @@ def _show_version(self) -> None: if versions: latest_version, current_version = versions if not current_version: - self.logger.warning("Can't get the current version of the package.") + self.logger.debug("Can't get the current version of the package.") return st.write(f"`{current_version}`") if self.public: @@ -233,10 +233,6 @@ def provider_info(self) -> None: if provider.instructions() is not None: st.write(provider.instructions()) - if provider_code == "srtm30": - self.multiplier_setter = st.checkbox( - "Apply the default multiplier", key="multiplier_setter" - ) if provider.settings() is not None: provider_settings = provider.settings()() @@ -325,7 +321,6 @@ def add_left_widgets(self) -> None: disabled=self.public, on_change=self.provider_info, ) - self.multiplier_setter = False self.provider_settings = None self.provider_info_container = st.empty() self.provider_info() @@ -519,9 +514,6 @@ def generate_map(self) -> None: # Limit settings on the public server. json_settings = self.limit_on_public(json_settings) - if self.multiplier_setter: - json_settings["DEMSettings"]["multiplier"] = 255 - # Parse settings from the JSON. all_settings = mfs.SettingsModel.all_settings_from_json(json_settings)