From 31802cbed1f8600783247a13b45a5c9ae21adb39 Mon Sep 17 00:00:00 2001
From: Stan Soldatov <118521851+iwatkot@users.noreply.github.com>
Date: Sat, 21 Dec 2024 21:12:37 +0100
Subject: [PATCH] Automatically add deco foliage
* Version check dsiabled for SL community.
* FAQ update.
* Changing the Sun bbox.
* Linter updates.
* Generating deco foliage.
* Rotation support for grass.
* README update.
---
README.md | 3 +
data/fs25-texture-schema.json | 11 ++-
docs/FAQ.md | 4 ++
maps4fs/generator/game.py | 15 ++++
maps4fs/generator/grle.py | 129 ++++++++++++++++++++++++++++++++++
maps4fs/generator/i3d.py | 15 ++++
maps4fs/generator/texture.py | 12 ++++
webui/generator.py | 42 +++++------
8 files changed, 209 insertions(+), 22 deletions(-)
diff --git a/README.md b/README.md
index 01ec771..cd96971 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@
🔄 Support map rotation 🆕
🌾 Automatically generates fields 🆕
🌽 Automatically generates farmlands 🆕
+🌿 Automatically generates decorative foliage 🆕
🌍 Based on real-world data from OpenStreetMap
🏞️ Generates height map using SRTM dataset
📦 Provides a ready-to-use map template for the Giants Editor
@@ -59,6 +60,8 @@
🛰️ Realistic background terrain with satellite images.
📐 Perfectly aligned background terrain.
+
+🌿 Automatically generates decorative foliage.
🗒️ True-to-life blueprints for fast and precise modding.
diff --git a/data/fs25-texture-schema.json b/data/fs25-texture-schema.json
index 14554ac..998f6ea 100644
--- a/data/fs25-texture-schema.json
+++ b/data/fs25-texture-schema.json
@@ -166,7 +166,10 @@
},
{
"name": "mudPebblesLight",
- "count": 2
+ "count": 2,
+ "tags": { "waterway": ["ditch", "drain"] },
+ "width": 2,
+ "color": [33, 67, 101]
},
{
"name": "mudTracks",
@@ -199,7 +202,11 @@
{
"name": "sand",
"count": 2,
- "tags": { "natural": "water", "waterway": true, "landuse": "basin" },
+ "tags": {
+ "natural": "water",
+ "waterway": ["river", "stream", "flowline", "tidal_channel", "canal"],
+ "landuse": "basin"
+ },
"width": 10,
"color": [255, 20, 20]
}
diff --git a/docs/FAQ.md b/docs/FAQ.md
index 77ee600..ceb0865 100644
--- a/docs/FAQ.md
+++ b/docs/FAQ.md
@@ -46,4 +46,8 @@ setShapeCullingWorldProperties(-8192, -100, -8192, 8192, 500, 8192, 16, 0, 9000)
Make sure that **Always loaded** checkbox is checked, then save it and execute. It should help.
+### I launched the script to download satellite images from QGIS, but it's taking too long, what should I do?
+
+The script is downloading a huge GeoTIFF image, so it can take a while depending on the region size and hardware. Some guys reported that on old CPUs it can take up to 30 minutes. Just wait, and it will finish eventually.
+
If you think that some question should be added here, please, contact me in [Discord](https://discord.gg/Sj5QKKyE42) or open an issue on GitHub. Thank you!
\ No newline at end of file
diff --git a/maps4fs/generator/game.py b/maps4fs/generator/game.py
index e576a83..186154e 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
+ _base_image: str | None = None
# Order matters! Some components depend on others.
components = [Texture, I3d, GRLE, Background, Config]
@@ -130,6 +131,19 @@ def weights_dir_path(self, map_directory: str) -> str:
str: The path to the weights directory."""
raise NotImplementedError
+ def base_image_path(self, map_directory: str) -> str | None:
+ """Returns the path to the base density map image.
+
+ Arguments:
+ map_directory (str): The path to the map directory.
+
+ Returns:
+ str: The path to the base density map image or None if not set.
+ """
+ if self._base_image:
+ return os.path.join(self.weights_dir_path(map_directory), self._base_image)
+ return None
+
def i3d_file_path(self, map_directory: str) -> str:
"""Returns the path to the i3d 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")
+ _base_image = "base.png"
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 c48fd1e..5b33f29 100644
--- a/maps4fs/generator/grle.py
+++ b/maps4fs/generator/grle.py
@@ -2,6 +2,7 @@
import json
import os
+from random import choice, randint
from xml.etree import ElementTree as ET
import cv2
@@ -9,6 +10,9 @@
from maps4fs.generator.component import Component
+ISLAND_SIZE_MIN = 10
+ISLAND_SIZE_MAX = 200
+
# pylint: disable=W0223
class GRLE(Component):
@@ -76,6 +80,11 @@ def process(self) -> None:
self.logger.warning("Invalid InfoLayer schema: %s.", info_layer)
self._add_farmlands()
+ if self.game.code == "FS25":
+ self.logger.info("Game is %s, plants will be added.", self.game.code)
+ self._add_plants()
+ else:
+ self.logger.warning("Adding plants it's not supported for the %s.", self.game.code)
def previews(self) -> list[str]:
"""Returns a list of paths to the preview images (empty list).
@@ -184,3 +193,123 @@ def _add_farmlands(self) -> None:
self.logger.info(
"Farmlands added to the InfoLayer PNG file: %s.", info_layer_farmlands_path
)
+
+ def _add_plants(self) -> None:
+ """Adds plants to the InfoLayer PNG file."""
+ # 1. Get the path to the densityMap_fruits.png.
+ # 2. Get the path to the base layer (grass).
+ # 3. Detect non-zero areas in the base layer (it's where the plants will be placed).
+ base_image_path = self.game.base_image_path(self.map_directory)
+ if not base_image_path or not os.path.isfile(base_image_path):
+ self.logger.warning("Base image not found in %s.", base_image_path)
+ return
+
+ density_map_fruit_path = os.path.join(
+ self.game.weights_dir_path(self.map_directory), "densityMap_fruits.png"
+ )
+
+ if not os.path.isfile(density_map_fruit_path):
+ self.logger.warning("Density map for fruits not found in %s.", density_map_fruit_path)
+ return
+
+ # Single channeled 8-bit image, where non-zero values (255) are where the grass is.
+ base_image = cv2.imread(base_image_path, cv2.IMREAD_UNCHANGED) # pylint: disable=no-member
+
+ # Density map of the fruits is 2X size of the base image, so we need to resize it.
+ # We'll resize the base image to make it bigger, so we can compare the values.
+ base_image = cv2.resize( # pylint: disable=no-member
+ base_image,
+ (base_image.shape[1] * 2, base_image.shape[0] * 2),
+ interpolation=cv2.INTER_NEAREST, # pylint: disable=no-member
+ )
+
+ # B and G channels remain the same (zeros), while we change the R channel.
+ possible_R_values = [33, 65, 97, 129, 161, 193, 225] # pylint: disable=C0103
+
+ # 1st approach: Change the non zero values in the base image to 33 (for debug).
+ # And use the base image as R channel in the density map.
+
+ # pylint: disable=no-member
+ def create_island_of_plants(image: np.ndarray, count: int) -> np.ndarray:
+ """Create an island of plants in the image.
+
+ Arguments:
+ image (np.ndarray): The image where the island of plants will be created.
+ count (int): The number of islands of plants to create.
+
+ Returns:
+ np.ndarray: The image with the islands of plants.
+ """
+ for _ in range(count):
+ # Randomly choose the value for the island.
+ plant_value = choice(possible_R_values)
+ # Randomly choose the size of the island.
+ island_size = randint(ISLAND_SIZE_MIN, ISLAND_SIZE_MAX)
+ # Randomly choose the position of the island.
+ # x = np.random.randint(0, image.shape[1] - island_size)
+ # y = np.random.randint(0, image.shape[0] - island_size)
+ x = randint(0, image.shape[1] - island_size)
+ y = randint(0, image.shape[0] - island_size)
+
+ # Randomly choose the shape of the island.
+ shapes = ["circle", "ellipse", "polygon"]
+ shape = choice(shapes)
+
+ try:
+ if shape == "circle":
+ center = (x + island_size // 2, y + island_size // 2)
+ radius = island_size // 2
+ cv2.circle(image, center, radius, plant_value, -1) # type: ignore
+ elif shape == "ellipse":
+ center = (x + island_size // 2, y + island_size // 2)
+ axes = (island_size // 2, island_size // 4)
+ angle = 0
+ cv2.ellipse( # type: ignore
+ image, center, axes, angle, 0, 360, plant_value, -1
+ )
+ elif shape == "polygon":
+ nodes_count = randint(20, 50)
+ nodes = []
+ for _ in range(nodes_count):
+ node = (randint(x, x + island_size), randint(y, y + island_size))
+ nodes.append(node)
+ nodes = np.array(nodes, np.int32) # type: ignore
+ cv2.fillPoly(image, [nodes], plant_value) # type: ignore
+ except Exception: # pylint: disable=W0703
+ continue
+
+ return image
+
+ updated_base_image = base_image.copy()
+ # Set all the non-zero values to 33.
+ updated_base_image[base_image != 0] = 33
+
+ # 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)
+ updated_base_image = create_island_of_plants(updated_base_image, island_count)
+ self.logger.debug("Islands of plants added to the base image.")
+
+ # Remove the values where the base image has zeros.
+ updated_base_image[base_image == 0] = 0
+ self.logger.debug("Removed the values where the base image has zeros.")
+
+ # Value of 33 represents the base grass plant.
+ # After painting it with base grass, we'll create multiple islands of different plants.
+ # On the final step, we'll remove all the values which in pixels
+ # where zerons in the original base image (so we don't paint grass where it should not be).
+
+ # Three channeled 8-bit image, where non-zero values are the
+ # different types of plants (only in the R channel).
+ density_map_fruits = cv2.imread(density_map_fruit_path, cv2.IMREAD_UNCHANGED)
+ self.logger.debug("Density map for fruits loaded, shape: %s.", density_map_fruits.shape)
+
+ # Put the updated base image as the B channel in the density map.
+ density_map_fruits[:, :, 0] = updated_base_image
+ self.logger.debug("Updated base image added as the B channel in the density map.")
+
+ # Save the updated density map.
+ # Ensure that order of channels is correct because CV2 uses BGR and we need RGB.
+ density_map_fruits = cv2.cvtColor(density_map_fruits, cv2.COLOR_BGR2RGB)
+ cv2.imwrite(density_map_fruit_path, density_map_fruits)
+ self.logger.info("Updated density map for fruits saved in %s.", density_map_fruit_path)
diff --git a/maps4fs/generator/i3d.py b/maps4fs/generator/i3d.py
index e87c49c..2bb750a 100644
--- a/maps4fs/generator/i3d.py
+++ b/maps4fs/generator/i3d.py
@@ -85,6 +85,21 @@ def _update_i3d_file(self) -> None:
self.logger.debug("TerrainTransformGroup element updated in I3D file.")
+ sun_elem = map_elem.find(".//Light[@name='sun']")
+
+ if sun_elem is not None:
+ self.logger.debug("Sun element found in I3D file.")
+
+ distance = self.map_size // 2
+
+ sun_elem.set("lastShadowMapSplitBboxMin", f"-{distance},-128,-{distance}")
+ sun_elem.set("lastShadowMapSplitBboxMax", f"{distance},148,{distance}")
+
+ self.logger.debug(
+ "Sun BBOX updated with half of the map size: %s.",
+ distance,
+ )
+
if self.map_size > 4096:
displacement_layer = terrain_elem.find(".//DisplacementLayer") # pylint: disable=W0631
diff --git a/maps4fs/generator/texture.py b/maps4fs/generator/texture.py
index b1ce7d8..9ae211c 100644
--- a/maps4fs/generator/texture.py
+++ b/maps4fs/generator/texture.py
@@ -5,6 +5,7 @@
import json
import os
import re
+import shutil
from collections import defaultdict
from typing import Any, Callable, Generator, Optional
@@ -242,6 +243,17 @@ def rotate_textures(self) -> None:
"Skipping rotation of layer %s because it has no tags.", layer.name
)
+ base_path = self.game.base_image_path(self.map_directory)
+ if base_path:
+ base_layer = self.get_base_layer()
+ if base_layer:
+ base_layer_path = base_layer.get_preview_or_path(self._weights_dir)
+ self.logger.debug(
+ "Copying base layer to use it later for density map to %s.", base_path
+ )
+ # Make a copy of a base layer to the fruits density map.
+ shutil.copy(base_layer_path, base_path)
+
# pylint: disable=W0201
def _read_parameters(self) -> None:
"""Reads map parameters from OSM data, such as:
diff --git a/webui/generator.py b/webui/generator.py
index 16c62e4..4ea0537 100644
--- a/webui/generator.py
+++ b/webui/generator.py
@@ -45,7 +45,7 @@ class GeneratorUI:
def __init__(self):
self.download_path = None
- self.logger = mfs.Logger(level="DEBUG", to_file=False)
+ self.logger = mfs.Logger(level="INFO", to_file=False)
self.community = config.is_on_community_server()
self.logger.debug("The application launched on the community server: %s", self.community)
@@ -116,25 +116,27 @@ def add_left_widgets(self) -> None:
self.logger.debug("Adding widgets to the left column...")
st.title(Messages.TITLE)
- versions = config.get_versions(self.logger)
- try:
- if versions:
- latest_version, current_version = versions
- if current_version != latest_version and len(current_version) > 0:
- st.warning(
- f"🆕 New version is available! \n"
- f"Your current version: `{current_version}`, "
- f"latest version: `{latest_version}`. \n"
- "Use the following commands to upgrade: \n"
- "```bash \n"
- "docker stop maps4fs \n"
- "docker rm maps4fs \n"
- "docker run -d -p 8501:8501 --name maps4fs "
- f"iwatkot/maps4fs:{latest_version} \n"
- "```"
- )
- except Exception as e:
- self.logger.error("An error occurred while checking the package version: %s", e)
+
+ if not self.community:
+ versions = config.get_versions(self.logger)
+ try:
+ if versions:
+ latest_version, current_version = versions
+ if current_version != latest_version and len(current_version) > 0:
+ st.warning(
+ f"🆕 New version is available! \n"
+ f"Your current version: `{current_version}`, "
+ f"latest version: `{latest_version}`. \n"
+ "Use the following commands to upgrade: \n"
+ "```bash \n"
+ "docker stop maps4fs \n"
+ "docker rm maps4fs \n"
+ "docker run -d -p 8501:8501 --name maps4fs "
+ f"iwatkot/maps4fs:{latest_version} \n"
+ "```"
+ )
+ except Exception as e:
+ self.logger.error("An error occurred while checking the package version: %s", e)
st.write(Messages.MAIN_PAGE_DESCRIPTION)
if self.community: