diff --git a/data/fs25-map-template.zip b/data/fs25-map-template.zip index 16def5ed..1c156d09 100644 Binary files a/data/fs25-map-template.zip and b/data/fs25-map-template.zip differ diff --git a/data/fs25-tree-schema.json b/data/fs25-tree-schema.json new file mode 100644 index 00000000..597dc55a --- /dev/null +++ b/data/fs25-tree-schema.json @@ -0,0 +1,338 @@ +[ + { + "name": "americanElmRavaged", + "reference_id": 1000 + }, + { + "name": "americanElm_stage01", + "reference_id": 1001 + }, + { + "name": "americanElm_stage02", + "reference_id": 1002 + }, + { + "name": "americanElm_stage03", + "reference_id": 1003 + }, + { + "name": "americanElm_stage04", + "reference_id": 1004 + }, + { + "name": "americanElm_stage05", + "reference_id": 1005 + }, + { + "name": "apple_stage03", + "reference_id": 1006 + }, + { + "name": "aspen_stage01", + "reference_id": 1007 + }, + { + "name": "aspen_stage02", + "reference_id": 1008 + }, + { + "name": "aspen_stage03", + "reference_id": 1009 + }, + { + "name": "aspen_stage04", + "reference_id": 1010 + }, + { + "name": "aspen_stage05", + "reference_id": 1011 + }, + { + "name": "aspen_stage06_var01", + "reference_id": 1012 + }, + { + "name": "aspen_stage06_var02", + "reference_id": 1013 + }, + { + "name": "beech_stage01", + "reference_id": 1014 + }, + { + "name": "beech_stage02", + "reference_id": 1015 + }, + { + "name": "beech_stage02_var02", + "reference_id": 1016 + }, + { + "name": "beech_stage02_var03", + "reference_id": 1017 + }, + { + "name": "beech_stage03", + "reference_id": 1018 + }, + { + "name": "beech_stage04", + "reference_id": 1019 + }, + { + "name": "beech_stage05", + "reference_id": 1020 + }, + { + "name": "beech_stage06_var01", + "reference_id": 1021 + }, + { + "name": "beech_stage06_var02", + "reference_id": 1022 + }, + { + "name": "betulaErmanii_stage01", + "reference_id": 1023 + }, + { + "name": "betulaErmanii_stage02", + "reference_id": 1024 + }, + { + "name": "betulaErmanii_stage03", + "reference_id": 1025 + }, + { + "name": "betulaErmanii_stage04", + "reference_id": 1026 + }, + { + "name": "boxelder_stage01", + "reference_id": 1027 + }, + { + "name": "boxelder_stage02", + "reference_id": 1028 + }, + { + "name": "boxelder_stage03", + "reference_id": 1029 + }, + { + "name": "cherry_stage01", + "reference_id": 1030 + }, + { + "name": "cherry_stage02", + "reference_id": 1031 + }, + { + "name": "cherry_stage03", + "reference_id": 1032 + }, + { + "name": "cherry_stage04", + "reference_id": 1033 + }, + { + "name": "chineseElm_stage01", + "reference_id": 1034 + }, + { + "name": "chineseElm_stage02", + "reference_id": 1035 + }, + { + "name": "chineseElm_stage03", + "reference_id": 1036 + }, + { + "name": "chineseElm_stage04", + "reference_id": 1037 + }, + { + "name": "deadwood", + "reference_id": 1038 + }, + { + "name": "downyServiceBerry_stage01", + "reference_id": 1039 + }, + { + "name": "downyServiceBerry_stage02", + "reference_id": 1040 + }, + { + "name": "downyServiceBerry_stage03", + "reference_id": 1041 + }, + { + "name": "goldenRain_stage01", + "reference_id": 1042 + }, + { + "name": "goldenRain_stage02", + "reference_id": 1043 + }, + { + "name": "goldenRain_stage03", + "reference_id": 1044 + }, + { + "name": "goldenRain_stage04", + "reference_id": 1045 + }, + { + "name": "japaneseZelkova_stage01", + "reference_id": 1046 + }, + { + "name": "japaneseZelkova_stage02", + "reference_id": 1047 + }, + { + "name": "japaneseZelkova_stage03", + "reference_id": 1048 + }, + { + "name": "japaneseZelkova_stage04", + "reference_id": 1049 + }, + { + "name": "lodgepolePine_stage01", + "reference_id": 1050 + }, + { + "name": "lodgepolePine_stage02", + "reference_id": 1051 + }, + { + "name": "lodgepolePine_stage02Var2", + "reference_id": 1052 + }, + { + "name": "lodgepolePine_stage03", + "reference_id": 1053 + }, + { + "name": "lodgepolePine_stage03Var2", + "reference_id": 1054 + }, + { + "name": "northernCatalpa_stage01", + "reference_id": 1055 + }, + { + "name": "northernCatalpa_stage02", + "reference_id": 1056 + }, + { + "name": "northernCatalpa_stage03", + "reference_id": 1057 + }, + { + "name": "northernCatalpa_stage04", + "reference_id": 1058 + }, + { + "name": "oak_stage01", + "reference_id": 1059 + }, + { + "name": "oak_stage02", + "reference_id": 1060 + }, + { + "name": "oak_stage03", + "reference_id": 1061 + }, + { + "name": "oak_stage04", + "reference_id": 1062 + }, + { + "name": "oak_stage05", + "reference_id": 1063 + }, + { + "name": "pinusSylvestris_stage01", + "reference_id": 1064 + }, + { + "name": "pinusSylvestris_stage02", + "reference_id": 1065 + }, + { + "name": "pinusSylvestris_stage03", + "reference_id": 1066 + }, + { + "name": "pinusSylvestris_stage04", + "reference_id": 1067 + }, + { + "name": "pinusSylvestris_stage05", + "reference_id": 1068 + }, + { + "name": "pinusTabuliformis_stage01", + "reference_id": 1069 + }, + { + "name": "pinusTabuliformis_stage02", + "reference_id": 1070 + }, + { + "name": "pinusTabuliformis_stage03", + "reference_id": 1071 + }, + { + "name": "pinusTabuliformis_stage04", + "reference_id": 1072 + }, + { + "name": "pinusTabuliformis_stage05", + "reference_id": 1073 + }, + { + "name": "shagbarkHickory_stage01", + "reference_id": 1074 + }, + { + "name": "shagbarkHickory_stage02", + "reference_id": 1075 + }, + { + "name": "shagbarkHickory_stage03", + "reference_id": 1076 + }, + { + "name": "shagbarkHickory_stage04", + "reference_id": 1077 + }, + { + "name": "tiliaAmurensis_stage01", + "reference_id": 1078 + }, + { + "name": "tiliaAmurensis_stage02", + "reference_id": 1079 + }, + { + "name": "tiliaAmurensis_stage03", + "reference_id": 1080 + }, + { + "name": "tiliaAmurensis_stage04", + "reference_id": 1081 + }, + { + "name": "transportTree", + "reference_id": 1082 + }, + { + "name": "treesRavaged", + "reference_id": 1083 + } +] diff --git a/maps4fs/generator/game.py b/maps4fs/generator/game.py index e576a833..c4f362b2 100644 --- a/maps4fs/generator/game.py +++ b/maps4fs/generator/game.py @@ -36,6 +36,7 @@ class Game: _map_template_path: str | None = None _texture_schema: str | None = None _grle_schema: str | None = None + _tree_schema: str | None = None # Order matters! Some components depend on others. components = [Texture, I3d, GRLE, Background, Config] @@ -109,6 +110,19 @@ def grle_schema(self) -> str: raise ValueError("GRLE layers schema path not set.") return self._grle_schema + @property + def tree_schema(self) -> str: + """Returns the path to the tree layers schema file. + + Raises: + ValueError: If the tree layers schema path is not set. + + Returns: + str: The path to the tree layers schema file.""" + if not self._tree_schema: + raise ValueError("Tree layers schema path not set.") + return self._tree_schema + def dem_file_path(self, map_directory: str) -> str: """Returns the path to the DEM file. @@ -187,6 +201,7 @@ class FS25(Game): _map_template_path = os.path.join(working_directory, "data", "fs25-map-template.zip") _texture_schema = os.path.join(working_directory, "data", "fs25-texture-schema.json") _grle_schema = os.path.join(working_directory, "data", "fs25-grle-schema.json") + _tree_schema = os.path.join(working_directory, "data", "fs25-tree-schema.json") def dem_file_path(self, map_directory: str) -> str: """Returns the path to the DEM file. diff --git a/maps4fs/generator/grle.py b/maps4fs/generator/grle.py index c2c11fbe..2b0e61e3 100644 --- a/maps4fs/generator/grle.py +++ b/maps4fs/generator/grle.py @@ -218,6 +218,8 @@ def _add_plants(self) -> None: grass_image_path = grass_layer.get_preview_or_path(weights_directory) self.logger.debug("Grass image path: %s.", grass_image_path) + # TODO: Get the forest layer and combine it with the grass layer. + if not grass_image_path or not os.path.isfile(grass_image_path): self.logger.warning("Base image not found in %s.", grass_image_path) return diff --git a/maps4fs/generator/i3d.py b/maps4fs/generator/i3d.py index 2bb750af..af2821aa 100644 --- a/maps4fs/generator/i3d.py +++ b/maps4fs/generator/i3d.py @@ -4,15 +4,22 @@ import json import os +from random import choice +from typing import Generator from xml.etree import ElementTree as ET +import cv2 +import numpy as np + 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 -NODE_ID_STARTING_VALUE = 500 +NODE_ID_STARTING_VALUE = 2000 +TREE_NODE_ID_STARTING_VALUE = 4000 # pylint: disable=R0903 @@ -48,6 +55,7 @@ def process(self) -> None: """Updates the map I3D file with the default settings.""" self._update_i3d_file() self._add_fields() + self._add_forests() def _get_tree(self) -> ET.ElementTree | None: """Returns the ElementTree instance of the map I3D file.""" @@ -316,3 +324,112 @@ def create_attribute_node(name: str, attr_type: str, value: str) -> ET.Element: attribute_node.set("type", attr_type) attribute_node.set("value", value) return attribute_node + + def _add_forests(self) -> None: + try: + tree_schema_path = self.game.tree_schema + except ValueError: + self.logger.warning("Tree schema path not set for the Game %s.", self.game.code) + return + + if not os.path.isfile(tree_schema_path): + self.logger.warning("Tree schema file was not found: %s.", tree_schema_path) + return + + try: + with open(tree_schema_path, "r", encoding="utf-8") as tree_schema_file: + tree_schema: list[dict[str, str | int]] = json.load(tree_schema_file) + except json.JSONDecodeError as e: + self.logger.warning( + "Could not load tree schema from %s with error: %s", tree_schema_path, e + ) + return + + texture_component: Texture | None = self.map.get_component("Texture") + if not texture_component: + self.logger.warning("Texture component not found.") + return + + forest_layer = texture_component.get_layer_by_usage("forest") + + if not forest_layer: + self.logger.warning("Forest layer not found.") + return + + weights_directory = self.game.weights_dir_path(self.map_directory) + forest_image_path = forest_layer.get_preview_or_path(weights_directory) + + if not forest_image_path or not os.path.isfile(forest_image_path): + self.logger.warning("Forest image not found.") + return + + tree = self._get_tree() + if tree is None: + return + + # Find the element in the I3D file. + root = tree.getroot() + scene_node = root.find(".//Scene") + if scene_node is None: + self.logger.warning("Scene element not found in I3D file.") + return + + self.logger.debug("Scene element found in I3D file, starting to add forests.") + + node_id = TREE_NODE_ID_STARTING_VALUE + + # Create element. + trees_node = ET.Element("TransformGroup") + trees_node.set("name", "trees") + trees_node.set("translation", "0 400 0") + trees_node.set("nodeId", str(node_id)) + node_id += 1 + + forest_image = cv2.imread(forest_image_path, cv2.IMREAD_UNCHANGED) + + tree_count = 0 + for x, y in self.non_empty_pixels(forest_image, step=2): + xcs, ycs = self.top_left_coordinates_to_center((x, y)) + node_id += 1 + + # TODO: Randomize coordinates. + + random_tree = choice(tree_schema) + tree_name = random_tree["name"] + tree_id = random_tree["reference_id"] + + # + reference_node = ET.Element("ReferenceNode") + reference_node.set("name", tree_name) + reference_node.set("translation", f"{xcs} 0 {ycs}") + reference_node.set("rotation", "0 -0 0") + reference_node.set("referenceId", str(tree_id)) + reference_node.set("nodeId", str(node_id)) + + trees_node.append(reference_node) + tree_count += 1 + + scene_node.append(trees_node) + self.logger.info("Added %s trees to the I3D file.", tree_count) + + tree.write(self._map_i3d_path) # type: ignore + self.logger.info("Map I3D file saved to: %s.", self._map_i3d_path) + + @staticmethod + def non_empty_pixels( + image: np.ndarray, step: int = 1 + ) -> Generator[tuple[int, int], None, None]: + """Receives numpy array, which represents single-channeled image of uint8 type. + Yield coordinates of non-empty pixels (pixels with value greater than 0). + + Arguments: + image (np.ndarray): The image to get non-empty pixels from. + step (int, optional): The step to iterate through the image. Defaults to 1. + + Yields: + tuple[int, int]: The coordinates of non-empty pixels. + """ + for y, row in enumerate(image[::step]): + for x, value in enumerate(row[::step]): + if value > 0: + yield x * step, y * step diff --git a/maps4fs/generator/texture.py b/maps4fs/generator/texture.py index 9f7e5798..99351072 100644 --- a/maps4fs/generator/texture.py +++ b/maps4fs/generator/texture.py @@ -5,7 +5,6 @@ import json import os import re -import shutil from collections import defaultdict from typing import Any, Callable, Generator, Optional