diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 77a8ce92..8c9579b6 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -35,7 +35,7 @@ jobs: run: pylint maps4fs - name: Run pytest with coverage - run: pytest --cov=maps4fs --cov-report xml + run: pytest --cov=maps4fs/generator --cov-report xml - name: Download Code Climate test-reporter run: | diff --git a/README.md b/README.md index adb88c2c..79782c60 100644 --- a/README.md +++ b/README.md @@ -85,14 +85,7 @@ ## Quick Start There are several ways to use the tool. You obviously need the **first one**, but you can choose any of the others depending on your needs.
### 🚜 For most users -**Option 1:** Open the [maps4fs](https://maps4fs.streamlit.app) on StreamLit and generate a map template in a few clicks.
-Note, that StreamLit community hosting has some limitations, such as:
-1. Maximum map size is 4096x4096 meters.
-2. Advanced settings are disabled.
-3. Texture dissolving is disabled (they will look worse).

- -If you run the application locally, you won't have any of these limitations and will be able to generate maps of any size with any settings you want and nice looking textures.
-So, jump to [Docker version](#option-2-docker-version) to launch the tool with one command and get the full experience.
+**Option 1:** Open the [maps4fs](https://maps4fs.xyz) and generate a map template in a few clicks.
![Basic WebUI](https://github.com/user-attachments/assets/52f499cc-f28a-4da3-abef-0e818abe8dbe) @@ -140,13 +133,13 @@ Don't know where to start? Don't worry, just follow this [step-by-step guide](do ## How-To-Run -### Option 1: StreamLit -🟒 Recommended for all users. +### Option 1: Public version +🟒 Recommended for all users. πŸ› οΈ Don't need to install anything. -πŸ—ΊοΈ Supported map sizes: 2x2, 4x4 km. -βš™οΈ Advanced settings: disabled. -πŸ–ΌοΈ Texture dissolving: disabled. -Using the [StreamLit](https://maps4fs.streamlit.app) version of the tool is the easiest way to generate a map template. Just open the link and follow the instructions. +πŸ—ΊοΈ Supported map sizes: 2x2, 4x4, 8x8 km. +βš™οΈ Advanced settings: enabled. +πŸ–ΌοΈ Texture dissolving: enabled. +Using the public version on [maps4fs.xyz](https://maps4fs.xyz) is the easiest way to generate a map template. Just open the link and follow the instructions. Note: due to CPU and RAM limitations of the hosting, the generation may take some time. If you need faster processing, use the [Docker version](#option-2-docker-version).
Using it is easy and doesn't require any guides. Enjoy! @@ -463,6 +456,8 @@ You can also apply some advanced settings to the map generation process. Note th - Fields padding - this value (in meters) will be applied to each field, making it smaller. It's useful when the fields are too close to each other and you want to make them smaller. By default, it's set to 0. +- Texture dissolving - if enabled, the values from one layer will be splitted between different layers of texture, making it look more natural. By default, it's set to True. Can be turned of for faster processing. + ### Farmlands Advanced settings - Farmlands margin - this value (in meters) will be applied to each farmland, making it bigger. You can use the value to adjust how much the farmland should be bigger than the actual field. By default, it's set to 3. @@ -473,6 +468,12 @@ You can also apply some advanced settings to the map generation process. Note th - Random plants - when adding decorative foliage, enabling this option will add different species of plants to the map. If unchecked only basic grass (smallDenseMix) will be added. Defaults to True. +### Background terrain Advanced settings + +- Generate background - if enabled, the obj files for the background terrain will be generated. You can turn it off if you already have those files or don't need them. By default, it's set to True. + +- Generate water - if enabled, the water planes obj files will be generated. You can turn it off if you already have those files or don't need them. By default, it's set to True. + ## Resources In this section, you'll find a list of the resources that you need to create a map for the Farming Simulator.
To create a basic map, you only need the Giants Editor. But if you want to create a background terrain - the world around the map, so it won't look like it's floating in the void - you also need Blender and the Blender Exporter Plugins. To create realistic textures for the background terrain, the QGIS is required to obtain high-resolution satellite images.
diff --git a/maps4fs/__init__.py b/maps4fs/__init__.py index 74157001..84d236f6 100644 --- a/maps4fs/__init__.py +++ b/maps4fs/__init__.py @@ -1,4 +1,11 @@ # pylint: disable=missing-module-docstring from maps4fs.generator.game import Game -from maps4fs.generator.map import Map +from maps4fs.generator.map import ( + BackgroundSettings, + DEMSettings, + GRLESettings, + I3DSettings, + Map, + TextureSettings, +) from maps4fs.logger import Logger diff --git a/maps4fs/generator/background.py b/maps4fs/generator/background.py index 4f0e5751..b0ed70f4 100644 --- a/maps4fs/generator/background.py +++ b/maps4fs/generator/background.py @@ -13,12 +13,7 @@ import trimesh # type: ignore from maps4fs.generator.component import Component -from maps4fs.generator.dem import ( - DEFAULT_BLUR_RADIUS, - DEFAULT_MULTIPLIER, - DEFAULT_PLATEAU, - DEM, -) +from maps4fs.generator.dem import DEM from maps4fs.generator.texture import Texture DEFAULT_DISTANCE = 2048 @@ -46,8 +41,6 @@ class Background(Component): # pylint: disable=R0801 def preprocess(self) -> None: """Registers the DEMs for the background terrain.""" - self.light_version = self.kwargs.get("light_version", False) - self.water_depth = self.kwargs.get("water_depth", 0) self.stl_preview_path: str | None = None self.water_resources_path: str | None = None @@ -65,7 +58,7 @@ def preprocess(self) -> None: os.makedirs(self.background_directory, exist_ok=True) os.makedirs(self.water_directory, exist_ok=True) - autoprocesses = [self.kwargs.get("auto_process", False), False] + autoprocesses = [self.map.dem_settings.auto_process, False] self.output_paths = [ os.path.join(self.background_directory, f"{name}.png") for name in ELEMENTS ] @@ -83,11 +76,8 @@ def preprocess(self) -> None: self.rotation, self.map_directory, self.logger, - auto_process=autoprocess, - blur_radius=self.kwargs.get("blur_radius", DEFAULT_BLUR_RADIUS), - multiplier=self.kwargs.get("multiplier", DEFAULT_MULTIPLIER), - plateau=self.kwargs.get("plateau", DEFAULT_PLATEAU), ) + dem.auto_process = autoprocess dem.preprocess() dem.is_preview = self.is_preview(name) # type: ignore dem.set_output_resolution((self.rotated_size, self.rotated_size)) @@ -118,7 +108,7 @@ def process(self) -> None: if not dem.is_preview: # type: ignore shutil.copyfile(dem.dem_path, self.not_substracted_path) - if self.water_depth: + if self.map.dem_settings.water_depth: self.subtraction() for dem in self.dems: @@ -127,8 +117,9 @@ def process(self) -> None: if self.game.additional_dem_name is not None: self.make_copy(cutted_dem_path, self.game.additional_dem_name) - if not self.light_version: + if self.map.background_settings.generate_background: self.generate_obj_files() + if self.map.background_settings.generate_water: self.generate_water_resources_obj() else: self.logger.info("Light version is enabled, obj files will not be generated.") @@ -325,7 +316,7 @@ def plane_from_np( self.mesh_to_stl(mesh) else: if not include_zeros: - multiplier = self.kwargs.get("multiplier", DEFAULT_MULTIPLIER) + multiplier = self.map.dem_settings.multiplier if multiplier != 1: z_scaling_factor = 1 / multiplier else: @@ -485,8 +476,7 @@ def create_background_textures(self) -> None: rotation=self.rotation, map_directory=self.map_directory, logger=self.logger, - light_version=self.light_version, - custom_schema=background_layers, + custom_schema=background_layers, # type: ignore ) self.background_texture.preprocess() @@ -534,7 +524,7 @@ def subtraction(self) -> None: # Create a mask where water_resources_image is 255 (or not 0) # Subtract water_depth from dem_image where mask is True - dem_image[mask] = dem_image[mask] - self.water_depth + dem_image[mask] = dem_image[mask] - self.map.dem_settings.water_depth # Save the modified dem_image back to the output path cv2.imwrite(output_path, dem_image) diff --git a/maps4fs/generator/component.py b/maps4fs/generator/component.py index d01b6c8e..db888856 100644 --- a/maps4fs/generator/component.py +++ b/maps4fs/generator/component.py @@ -20,7 +20,7 @@ from maps4fs.generator.map import Map -# pylint: disable=R0801, R0903, R0902, R0904 +# pylint: disable=R0801, R0903, R0902, R0904, R0913, R0917 class Component: """Base class for all map generation components. @@ -46,7 +46,7 @@ def __init__( rotation: int, map_directory: str, logger: Any = None, - **kwargs, # pylint: disable=W0613, R0913, R0917 + **kwargs: dict[str, Any], ): self.game = game self.map = map @@ -58,6 +58,13 @@ def __init__( self.logger = logger self.kwargs = kwargs + self.logger.info( + "Component %s initialized. Map size: %s, map rotated size: %s", # type: ignore + self.__class__.__name__, + self.map_size, + self.map_rotated_size, + ) + os.makedirs(self.previews_directory, exist_ok=True) os.makedirs(self.scripts_directory, exist_ok=True) os.makedirs(self.info_layers_directory, exist_ok=True) diff --git a/maps4fs/generator/dem.py b/maps4fs/generator/dem.py index 3ece15aa..9d60f79e 100644 --- a/maps4fs/generator/dem.py +++ b/maps4fs/generator/dem.py @@ -14,9 +14,6 @@ from maps4fs.generator.component import Component SRTM = "https://elevation-tiles-prod.s3.amazonaws.com/skadi/{latitude_band}/{tile_name}.hgt.gz" -DEFAULT_MULTIPLIER = 1 -DEFAULT_BLUR_RADIUS = 35 -DEFAULT_PLATEAU = 0 # pylint: disable=R0903, R0902 @@ -50,8 +47,7 @@ def preprocess(self) -> None: self.output_resolution = self.get_output_resolution() self.logger.debug("Output resolution for DEM data: %s.", self.output_resolution) - self.multiplier = self.kwargs.get("multiplier", DEFAULT_MULTIPLIER) - blur_radius = self.kwargs.get("blur_radius", DEFAULT_BLUR_RADIUS) + blur_radius = self.map.dem_settings.blur_radius if blur_radius is None or blur_radius <= 0: # We'll disable blur if the radius is 0 or negative. blur_radius = 0 @@ -59,11 +55,12 @@ def preprocess(self) -> None: blur_radius += 1 self.blur_radius = blur_radius self.logger.debug( - "DEM value multiplier is %s, blur radius is %s.", self.multiplier, self.blur_radius + "DEM value multiplier is %s, blur radius is %s.", + self.map.dem_settings.multiplier, + self.blur_radius, ) - self.auto_process = self.kwargs.get("auto_process", False) - self.plateau = self.kwargs.get("plateau", False) + self.auto_process = self.map.dem_settings.auto_process @property def dem_path(self) -> str: @@ -191,11 +188,11 @@ def process(self) -> None: resampled_data = self._normalize_dem(resampled_data) else: self.logger.debug("Auto processing is disabled, DEM data will not be normalized.") - resampled_data = resampled_data * self.multiplier + resampled_data = resampled_data * self.map.dem_settings.multiplier self.logger.debug( "DEM data was multiplied by %s. Min: %s, max: %s. Data type: %s.", - self.multiplier, + self.map.dem_settings.multiplier, resampled_data.min(), resampled_data.max(), resampled_data.dtype, @@ -210,7 +207,7 @@ def process(self) -> None: self.logger.debug( "DEM data was multiplied by %s and clipped to 16-bit unsigned integer range. " "Min: %s, max: %s.", - self.multiplier, + self.map.dem_settings.multiplier, resampled_data.min(), resampled_data.max(), ) @@ -240,18 +237,18 @@ def process(self) -> None: resampled_data.max(), ) - if self.plateau: + if self.map.dem_settings.plateau: # Plateau is a flat area with a constant height. # So we just add this value to each pixel of the DEM. # And also need to ensure that there will be no values with height greater than # it's allowed in 16-bit unsigned integer. - resampled_data += self.plateau + resampled_data += self.map.dem_settings.plateau resampled_data = np.clip(resampled_data, 0, 65535) self.logger.debug( "Plateau with height %s was added to DEM data. Min: %s, max: %s.", - self.plateau, + self.map.dem_settings.plateau, resampled_data.min(), resampled_data.max(), ) diff --git a/maps4fs/generator/grle.py b/maps4fs/generator/grle.py index 4b2656e9..fc62ddc1 100644 --- a/maps4fs/generator/grle.py +++ b/maps4fs/generator/grle.py @@ -40,9 +40,6 @@ def preprocess(self) -> None: """Gets the path to the map I3D file from the game instance and saves it to the instance attribute. If the game does not support I3D files, the attribute is set to None.""" - self.farmland_margin = self.kwargs.get("farmland_margin", 0) - self.randomize_plants = self.kwargs.get("randomize_plants", True) - try: grle_schema_path = self.game.grle_schema except ValueError: @@ -148,7 +145,7 @@ def _add_farmlands(self) -> None: for field in fields: try: fitted_field = self.fit_polygon_into_bounds( - field, self.farmland_margin, angle=self.rotation + field, self.map.grle_settings.farmland_margin, angle=self.rotation ) except ValueError as e: self.logger.warning( @@ -358,7 +355,7 @@ def get_rounded_polygon( # Add islands of plants to the base image. island_count = self.map_size self.logger.info("Adding %s islands of plants to the base image.", island_count) - if self.randomize_plants: + if self.map.grle_settings.random_plants: grass_image_copy = create_island_of_plants(grass_image_copy, island_count) self.logger.debug("Islands of plants added to the base image.") diff --git a/maps4fs/generator/i3d.py b/maps4fs/generator/i3d.py index 99bb5227..d57fab61 100644 --- a/maps4fs/generator/i3d.py +++ b/maps4fs/generator/i3d.py @@ -43,8 +43,6 @@ class I3d(Component): def preprocess(self) -> None: """Gets the path to the map I3D file from the game instance and saves it to the instance attribute. If the game does not support I3D files, the attribute is set to None.""" - self.auto_process = self.kwargs.get("auto_process", False) - try: self._map_i3d_path = self.game.i3d_file_path(self.map_directory) self.logger.debug("Map I3D path: %s.", self._map_i3d_path) @@ -52,9 +50,6 @@ def preprocess(self) -> None: self.logger.info("I3D file processing is not implemented for this game.") self._map_i3d_path = None - self.forest_density = self.kwargs.get("forest_density", DEFAULT_FOREST_DENSITY) - self.logger.info("Forest density: %s.", self.forest_density) - def process(self) -> None: """Updates the map I3D file with the default settings.""" self._update_i3d_file() @@ -84,7 +79,7 @@ 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.auto_process: + if self.map.dem_settings.auto_process: terrain_elem.set("heightScale", str(DEFAULT_HEIGHT_SCALE)) self.logger.debug( "heightScale attribute set to %s in TerrainTransformGroup element.", @@ -395,12 +390,14 @@ def _add_forests(self) -> None: forest_image = cv2.imread(forest_image_path, cv2.IMREAD_UNCHANGED) tree_count = 0 - for x, y in self.non_empty_pixels(forest_image, step=self.forest_density): + for x, y in self.non_empty_pixels(forest_image, step=self.map.i3d_settings.forest_density): xcs, ycs = self.top_left_coordinates_to_center((x, y)) node_id += 1 rotation = randint(-180, 180) - xcs, ycs = self.randomize_coordinates((xcs, ycs), self.forest_density) # type: ignore + xcs, ycs = self.randomize_coordinates( # type: ignore + (xcs, ycs), self.map.i3d_settings.forest_density + ) random_tree = choice(tree_schema) tree_name = random_tree["name"] diff --git a/maps4fs/generator/map.py b/maps4fs/generator/map.py index 2c5ab207..391d1185 100644 --- a/maps4fs/generator/map.py +++ b/maps4fs/generator/map.py @@ -4,13 +4,79 @@ import os import shutil -from typing import Any, Generator +from typing import Any, Generator, NamedTuple from maps4fs.generator.component import Component from maps4fs.generator.game import Game from maps4fs.logger import Logger +class DEMSettings(NamedTuple): + """Represents the advanced settings for DEM component. + + Attributes: + auto_process (bool): use the auto preset to change the multiplier. + multiplier (int): multiplier for the heightmap, every pixel will be multiplied by this + value. + blur_radius (int): radius of the blur filter. + plateau (int): plateau height, will be added to each pixel. + water_depth (int): water depth, will be subtracted from each pixel where the water + is present. + """ + + auto_process: bool = True + multiplier: int = 1 + blur_radius: int = 35 + plateau: int = 0 + water_depth: int = 0 + + +class BackgroundSettings(NamedTuple): + """Represents the advanced settings for background component. + + Attributes: + generate_background (bool): generate obj files for the background terrain. + generate_water (bool): generate obj files for the water. + """ + + generate_background: bool = True + generate_water: bool = True + + +class GRLESettings(NamedTuple): + """Represents the advanced settings for GRLE component. + + Attributes: + farmland_margin (int): margin around the farmland. + random_plants (bool): generate random plants on the map or use the default one. + """ + + farmland_margin: int = 0 + random_plants: bool = True + + +class I3DSettings(NamedTuple): + """Represents the advanced settings for I3D component. + + Attributes: + forest_density (int): density of the forest (distance between trees). + """ + + forest_density: int = 10 + + +class TextureSettings(NamedTuple): + """Represents the advanced settings for texture component. + + Attributes: + dissolve (bool): dissolve the texture into several images. + fields_padding (int): padding around the fields. + """ + + dissolve: bool = True + fields_padding: int = 0 + + # pylint: disable=R0913, R0902 class Map: """Class used to generate map using all components. @@ -31,7 +97,11 @@ def __init__( # pylint: disable=R0917 rotation: int, map_directory: str, logger: Any = None, - **kwargs, + dem_settings: DEMSettings = DEMSettings(), + background_settings: BackgroundSettings = BackgroundSettings(), + grle_settings: GRLESettings = GRLESettings(), + i3d_settings: I3DSettings = I3DSettings(), + texture_settings: TextureSettings = TextureSettings(), ): if not logger: logger = Logger(to_stdout=True, to_file=False) @@ -53,8 +123,16 @@ def __init__( # pylint: disable=R0917 self.logger.info("Game was set to %s", game.code) - self.kwargs = kwargs - self.logger.info("Additional arguments: %s", kwargs) + self.dem_settings = dem_settings + self.logger.info("DEM settings: %s", dem_settings) + self.background_settings = background_settings + self.logger.info("Background settings: %s", background_settings) + self.grle_settings = grle_settings + self.logger.info("GRLE settings: %s", grle_settings) + self.i3d_settings = i3d_settings + self.logger.info("I3D settings: %s", i3d_settings) + self.texture_settings = texture_settings + self.logger.info("Texture settings: %s", texture_settings) os.makedirs(self.map_directory, exist_ok=True) self.logger.debug("Map directory created: %s", self.map_directory) @@ -81,7 +159,6 @@ def generate(self) -> Generator[str, None, None]: self.rotation, self.map_directory, self.logger, - **self.kwargs, ) self.components.append(component) diff --git a/maps4fs/generator/texture.py b/maps4fs/generator/texture.py index 6f306396..99f455f2 100644 --- a/maps4fs/generator/texture.py +++ b/maps4fs/generator/texture.py @@ -177,16 +177,10 @@ def paths(self, weights_directory: str) -> list[str]: ] def preprocess(self) -> None: - self.light_version = self.kwargs.get("light_version", False) - self.fields_padding = self.kwargs.get("fields_padding", 0) - self.logger.debug("Light version: %s.", self.light_version) - - self.custom_schema: list[dict[str, str | dict[str, str] | int]] | None = self.kwargs.get( - "custom_schema" - ) - - if self.custom_schema: - layers_schema = self.custom_schema + """Preprocesses the data before the generation.""" + custom_schema = self.kwargs.get("custom_schema") + if custom_schema: + layers_schema = custom_schema # type: ignore self.logger.info("Custom schema loaded with %s layers.", len(layers_schema)) else: if not os.path.isfile(self.game.texture_schema): @@ -201,7 +195,7 @@ def preprocess(self) -> None: raise ValueError(f"Error loading texture layers schema: {e}") from e try: - self.layers = [self.Layer.from_json(layer) for layer in layers_schema] + self.layers = [self.Layer.from_json(layer) for layer in layers_schema] # type: ignore self.logger.info("Loaded %s layers.", len(self.layers)) except Exception as e: # pylint: disable=W0703 raise ValueError(f"Error loading texture layers: {e}") from e @@ -431,7 +425,9 @@ def draw(self) -> None: if cumulative_image is not None: self.draw_base_layer(cumulative_image) - if not self.light_version: + if self.map.texture_settings.dissolve and self.game.code != "FS22": + # FS22 has textures splitted into 4 sublayers, which leads to a very + # long processing time when dissolving them. self.dissolve() else: self.logger.debug("Skipping dissolve in light version of the map.") @@ -651,8 +647,8 @@ def polygons( if polygon is None: continue - if is_fieds and self.fields_padding > 0: - padded_polygon = polygon.buffer(-self.fields_padding) + if is_fieds and self.map.texture_settings.fields_padding > 0: + padded_polygon = polygon.buffer(-self.map.texture_settings.fields_padding) if not isinstance(padded_polygon, shapely.geometry.polygon.Polygon): self.logger.warning("The padding value is too high, field will not padded.") diff --git a/tests/test_generator.py b/tests/test_generator.py index 9fe5b110..9aeec41d 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -26,7 +26,6 @@ ] game_code_cases = ["FS25"] -autoprocess_cases = [True, False] def get_random_size() -> tuple[int, int]: @@ -70,61 +69,59 @@ def load_textures_schema(json_path: str) -> dict: def test_map(): """Test Map generation for different cases.""" for game_code in game_code_cases: - for autoprocess_case in autoprocess_cases: - game = Game.from_code(game_code) - for coordinates in coordinates_cases: - height, width = get_random_size() - directory = map_directory(game_code) - - map = Map( - game=game, - coordinates=coordinates, - size=height, - rotation=0, - map_directory=directory, - auto_process=autoprocess_case, - ) - - for _ in map.generate(): - pass - - layers_schema = load_textures_schema(game.texture_schema) - - texture_subdir = "maps/map/data" if game_code == "FS22" else "map/data" - - textures_directory = os.path.join(directory, texture_subdir) - for texture in layers_schema: - texture_name = texture["name"] - numer_of_layers = texture["count"] - - exclude_weight = texture.get("exclude_weight", False) - if exclude_weight: - continue - - if numer_of_layers == 0: - continue - for idx in range(1, numer_of_layers + 1): - texture_path = os.path.join( - textures_directory, f"{texture_name}{str(idx).zfill(2)}_weight.png" - ) - assert os.path.isfile(texture_path), f"Texture not found: {texture_path}" - img = cv2.imread(texture_path) - assert img is not None, f"Texture could not be read: {texture_path}" - assert img.shape == ( - height, - width, - 3, - ), f"Texture shape mismatch: {img.shape} != {(height, width, 3)}" - assert img.dtype == "uint8", f"Texture dtype mismatch: {img.dtype} != uint8" - - dem_name = "map_dem.png" if game_code == "FS22" else "dem.png" - - dem_file = os.path.join(textures_directory, dem_name) - assert os.path.isfile(dem_file), f"DEM file not found: {dem_file}" - img = cv2.imread(dem_file, cv2.IMREAD_UNCHANGED) - assert img is not None, f"DEM could not be read: {dem_file}" - - assert img.dtype == "uint16", f"DEM dtype mismatch: {img.dtype} != uint16" + game = Game.from_code(game_code) + for coordinates in coordinates_cases: + height, width = get_random_size() + directory = map_directory(game_code) + + map = Map( + game=game, + coordinates=coordinates, + size=height, + rotation=0, + map_directory=directory, + ) + + for _ in map.generate(): + pass + + layers_schema = load_textures_schema(game.texture_schema) + + texture_subdir = "maps/map/data" if game_code == "FS22" else "map/data" + + textures_directory = os.path.join(directory, texture_subdir) + for texture in layers_schema: + texture_name = texture["name"] + numer_of_layers = texture["count"] + + exclude_weight = texture.get("exclude_weight", False) + if exclude_weight: + continue + + if numer_of_layers == 0: + continue + for idx in range(1, numer_of_layers + 1): + texture_path = os.path.join( + textures_directory, f"{texture_name}{str(idx).zfill(2)}_weight.png" + ) + assert os.path.isfile(texture_path), f"Texture not found: {texture_path}" + img = cv2.imread(texture_path) + assert img is not None, f"Texture could not be read: {texture_path}" + assert img.shape == ( + height, + width, + 3, + ), f"Texture shape mismatch: {img.shape} != {(height, width, 3)}" + assert img.dtype == "uint8", f"Texture dtype mismatch: {img.dtype} != uint8" + + dem_name = "map_dem.png" if game_code == "FS22" else "dem.png" + + dem_file = os.path.join(textures_directory, dem_name) + assert os.path.isfile(dem_file), f"DEM file not found: {dem_file}" + img = cv2.imread(dem_file, cv2.IMREAD_UNCHANGED) + assert img is not None, f"DEM could not be read: {dem_file}" + + assert img.dtype == "uint16", f"DEM dtype mismatch: {img.dtype} != uint16" def test_map_preview(): @@ -169,7 +166,7 @@ def test_map_pack(): game=game, coordinates=case, size=height, - rotation=0, + rotation=30, map_directory=directory, ) for _ in map.generate(): diff --git a/webui/generator.py b/webui/generator.py index 2896cf65..979c8729 100644 --- a/webui/generator.py +++ b/webui/generator.py @@ -11,12 +11,10 @@ from templates import Messages import maps4fs as mfs -from maps4fs.generator.dem import ( - DEFAULT_BLUR_RADIUS, - DEFAULT_MULTIPLIER, - DEFAULT_PLATEAU, -) +DEFAULT_MULTIPLIER = 1 +DEFAULT_BLUR_RADIUS = 35 +DEFAULT_PLATEAU = 0 DEFAULT_LAT = 45.28571409289627 DEFAULT_LON = 20.237433441210115 Image.MAX_IMAGE_PIXELS = None @@ -47,9 +45,9 @@ def __init__(self): self.download_path = None self.logger = mfs.Logger(level="INFO", to_file=False) - self.community = config.is_on_community_server() + if config.is_on_community_server(): + st.toast(Messages.MOVED, icon="🚜") self.public = config.is_public() - self.logger.debug("The application launched on the community server: %s", self.community) self.logger.debug("The application launched on a public server: %s", self.public) self.left_column, self.right_column = st.columns(2, gap="large") @@ -120,7 +118,7 @@ def add_left_widgets(self) -> None: st.title(Messages.TITLE) # Only for a local Docker version. - if not self.community and not self.public: + if not self.public: versions = config.get_versions(self.logger) try: if versions: @@ -142,8 +140,6 @@ def add_left_widgets(self) -> None: self.logger.error("An error occurred while checking the package version: %s", e) st.write(Messages.MAIN_PAGE_DESCRIPTION) - if self.community: - st.info(Messages.MAIN_PAGE_COMMUNITY_WARNING) st.markdown("---") # Game selection (FS22 or FS25). @@ -169,10 +165,8 @@ def add_left_widgets(self) -> None: ) size_options = ["2048x2048", "4096x4096", "8192x8192", "16384x16384", "Custom"] - if self.community: - size_options = size_options[:1] if self.public: - size_options = size_options[:2] + size_options = size_options[:3] # Map size selection. st.write("Select size of the map:") @@ -200,13 +194,8 @@ def add_left_widgets(self) -> None: self.map_size_input = f"{custom_map_size_input}x{custom_map_size_input}" - if self.community or self.public: - st.warning( - "πŸ’‘ If you run the tool locally, you can generate larger maps, even with the custom size. \n" - ) - # Rotation input. - st.write("[BETA] Enter the rotation of the map:") + st.write("Enter the rotation of the map:") self.rotation = st.slider( "Rotation", @@ -216,11 +205,9 @@ def add_left_widgets(self) -> None: step=1, key="rotation", label_visibility="collapsed", - disabled=self.community, + disabled=False, on_change=self.map_preview, ) - if self.community: - st.warning("πŸ’‘ This feature is available in local version of the tool.") self.auto_process = st.checkbox("Use auto preset", value=True, key="auto_process") if self.auto_process: @@ -235,6 +222,9 @@ def add_left_widgets(self) -> None: self.forest_density = 10 self.randomize_plants = True self.water_depth = 200 + self.dissolving_enabled = True + self.generate_background = True + self.generate_water = True if not self.auto_process: self.logger.info("Auto preset is disabled.") @@ -329,6 +319,14 @@ def add_left_widgets(self) -> None: label_visibility="collapsed", ) + st.write("Dissolving:") + st.write(Messages.DISSOLVING_INFO) + self.dissolving_enabled = st.checkbox( + "Dissolving enabled", + value=True, + key="dissolving_enabled", + ) + with st.expander("Farmlands Advanced Settings", icon="🌾"): st.info( "ℹ️ Settings related to the farmlands of the map, which represent the lands " @@ -372,6 +370,26 @@ def add_left_widgets(self) -> None: "Random plants", value=True, key="randomize_plants" ) + with st.expander("Background Advanced Settings", icon="πŸ–ΌοΈ"): + st.info( + "ℹ️ Settings related to the background of the map, which represent the sky, " + "clouds, etc." + ) + + st.write("Generate background:") + st.write(Messages.GENERATE_BACKGROUND_INFO) + + self.generate_background = st.checkbox( + "Generate background", value=True, key="generate_background" + ) + + st.write("Generate water:") + st.write(Messages.GENERATE_WATER_INFO) + + self.generate_water = st.checkbox( + "Generate water", value=True, key="generate_water" + ) + # Add an empty container for status messages. self.status_container = st.empty() @@ -380,8 +398,9 @@ def add_left_widgets(self) -> None: # Generate button. with self.buttons_container: - if st.button("Generate", key="launch_btn"): - self.generate_map() + if not config.is_on_community_server(): + if st.button("Generate", key="launch_btn"): + self.generate_map() # Download button. if st.session_state.generated: @@ -396,7 +415,6 @@ def add_left_widgets(self) -> None: icon="πŸ“₯", ) - # st.info(f"The file will be removed in {int(config.REMOVE_DELAY / 60)} minutes.") config.remove_with_delay_without_blocking(self.download_path, self.logger) st.session_state.generated = False @@ -471,6 +489,33 @@ def generate_map(self) -> None: else self.plateau_height_input + self.water_depth ) + dem_settings = mfs.DEMSettings( + auto_process=self.auto_process, + multiplier=multiplier, + blur_radius=self.blur_radius_input, + plateau=plateau, + ) + self.logger.info("DEM settings: %s", dem_settings) + + background_settings = mfs.BackgroundSettings( + generate_background=self.generate_background, generate_water=self.generate_water + ) + self.logger.info("Background settings: %s", background_settings) + + grle_settings = mfs.GRLESettings( + farmland_margin=self.farmland_margin, + random_plants=self.randomize_plants, + ) + self.logger.info("GRLE settings: %s", grle_settings) + + i3d_settings = mfs.I3DSettings(forest_density=self.forest_density) + self.logger.info("I3D settings: %s", i3d_settings) + + texture_settings = mfs.TextureSettings( + dissolve=self.dissolving_enabled, fields_padding=self.fields_padding + ) + self.logger.info("Texture settings: %s", texture_settings) + mp = mfs.Map( game, coordinates, @@ -478,19 +523,14 @@ def generate_map(self) -> None: self.rotation, map_directory, logger=self.logger, - multiplier=multiplier, - blur_radius=self.blur_radius_input, - auto_process=self.auto_process, - plateau=plateau, - light_version=self.community, - fields_padding=self.fields_padding, - farmland_margin=self.farmland_margin, - forest_density=self.forest_density, - randomize_plants=self.randomize_plants, - water_depth=self.water_depth, + dem_settings=dem_settings, + background_settings=background_settings, + grle_settings=grle_settings, + i3d_settings=i3d_settings, + texture_settings=texture_settings, ) - if self.community or self.public: + if self.public: add_to_queue(session_name) for position in wait_in_queue(session_name): self.status_container.info( @@ -525,13 +565,13 @@ def generate_map(self) -> None: st.session_state.generated = True self.status_container.success("Map generation completed!", icon="βœ…") - except Exception as e: - self.logger.error("An error occurred while generating the map: %s", repr(e)) - self.status_container.error( - f"An error occurred while generating the map: {repr(e)}.", icon="❌" - ) + # except Exception as e: + # self.logger.error("An error occurred while generating the map: %s", repr(e)) + # self.status_container.error( + # f"An error occurred while generating the map: {repr(e)}.", icon="❌" + # ) finally: - if self.community or self.public: + if self.public: remove_from_queue(session_name) def show_preview(self, mp: mfs.Map) -> None: diff --git a/webui/templates.py b/webui/templates.py index 3e0fbc65..85ac3581 100644 --- a/webui/templates.py +++ b/webui/templates.py @@ -9,6 +9,7 @@ class Messages: "πŸ€— If you like the project, consider supporting it on [Buy Me a Coffee](https://www.buymeacoffee.com/iwatkot). \n" "πŸ“Ή A complete step-by-step video tutorial is on [YouTube](https://www.youtube.com/watch?v=Nl_aqXJ5nAk&)!" ) + MOVED = "The app has moved to [maps4fs.xyz](https://maps4fs.xyz)" MAIN_PAGE_COMMUNITY_WARNING = ( "🚜 Hey, farmer! \n" "Do you know what **Docker** is? If yes, please consider running the application " @@ -26,22 +27,7 @@ class Messages: "Also, if you are familiar with Python, you can use the " "[maps4fs](https://pypi.org/project/maps4fs/) package to generate maps locally." ) - # TERRAIN_RELOAD = ( - # "ℹ️ When opening the map first time in the Giants Editor, select the **terrain** object, " - # "open the **Terrain** tab in the **Attributes** window, scroll down to the end " - # "and press the **Reload material** button. \n" - # "Otherwise you may (and will) face some glitches." - # ) - # HEIGHT_SCALE_INFO = ( - # "ℹ️ Remember to adjust the ***heightScale*** parameter in the Giants Editor to a value " - # "that suits your map. Learn more about it in repo's " - # "[README](https://github.com/iwatkot/maps4fs?tab=readme-ov-file#For-advanced-users)." - # ) - COMMUNITY_ADVANCED_SETTINGS = ( - "πŸ’‘ Advanced settings are disabled on StreamLit community hosting. \n" - "If you want to have more control over the terrain generation, consider running the " - "application locally." - ) + TOOL_LOCAL = "πŸ’‘ This tool is available in the local version of the tool." AUTO_PRESET_INFO = ( "Auto preset will automatically apply different algorithms to make terrain more " @@ -114,3 +100,16 @@ class Messages: "difference. Also, this value will be added to the plateau value, to avoid negative " "height." ) + DISSOLVING_INFO = ( + "If enabled, the textures will be dissolved (splitted between different files). " + "It makes them look better in game, but it will require some time. " + "It's recommended to keep this option enabled." + ) + GENERATE_BACKGROUND_INFO = ( + "If enabled, the background terrain obj files will be generated to edit them in Blender. " + "Turn it off if you already have them or don't need them." + ) + GENERATE_WATER_INFO = ( + "If enabled, the water planes obj files will be generated to edit them in Blender. " + "Turn it off if you already have them or don't need them." + ) diff --git a/webui/tools/background.py b/webui/tools/background.py index 61ce1dca..f6428982 100644 --- a/webui/tools/background.py +++ b/webui/tools/background.py @@ -5,6 +5,7 @@ import streamlit as st from config import INPUT_DIRECTORY, is_on_community_server, is_public from tools.tool import Tool +from templates import Messages from maps4fs.toolbox.background import plane_from_np @@ -24,7 +25,7 @@ class ConvertImageToObj(Tool): def content(self): if is_on_community_server() or is_public(): - st.warning("πŸ’‘ This tool is available in the local version of the tool.") + st.warning(Messages.TOOL_LOCAL) return if "convertedtoobj" not in st.session_state: st.session_state.convertedtoobj = False diff --git a/webui/tools/dem.py b/webui/tools/dem.py index d26b29a8..6a2e6da3 100644 --- a/webui/tools/dem.py +++ b/webui/tools/dem.py @@ -5,6 +5,7 @@ from config import INPUT_DIRECTORY, is_on_community_server, is_public from osmp import get_bbox, get_center, get_preview from tools.tool import Tool +from templates import Messages from maps4fs.toolbox.dem import extract_roi, get_geo_tiff_bbox, read_geo_tiff @@ -23,7 +24,7 @@ class GeoTIFFWindowingTool(Tool): def content(self): if is_on_community_server() or is_public(): - st.warning("πŸ’‘ This tool is available in the local version of the tool.") + st.warning(Messages.TOOL_LOCAL) return if "windowed" not in st.session_state: st.session_state.windowed = False @@ -32,11 +33,6 @@ def content(self): with self.right_column: self.html_preview_container = st.empty() - # if uploaded_file is not None: - # self.save_path = self.get_save_path(uploaded_file.name) - # with open(self.save_path, "wb") as f: - # f.write(uploaded_file.read()) - if uploaded_file is not None: if not uploaded_file.name.lower().endswith((".tif", ".tiff")): st.error("Please upload correct GeoTIFF file.")