diff --git a/README.md b/README.md
index ea428efb..9b3a4913 100644
--- a/README.md
+++ b/README.md
@@ -336,6 +336,12 @@ Let's have a closer look at the fields:
## Background terrain
The tool now supports the generation of the background terrain. If you don't know what it is, here's a brief explanation. The background terrain is the world around the map. It's important to create it, because if you don't, the map will look like it's floating in the void. The background terrain is a simple plane which can (and should) be texture to look fine.
So, the tool generates the background terrain in the form of the 8 tiles, which surround the map. The tiles are named as the cardinal points, e.g. "N", "NE", "E" and so on. All those tiles will be saved in the `objects/tiles` directory with corresponding names: `N.obj`, `NE.obj`, `E.obj` and so on.
+If you don't want to work with separate tiles, the tool also generates the `FULL.obj` file, which includes everything around the map and the map itself. It may be a convinient approach to work with one file, one texture and then just cut the map from it.
+
+
+
+➡️ *No matter which approach you choose, you still need to adjust the background terrain to connect it to the map without any gaps. But with a sinlge file it's much easier to do.*
+
If you're willing to create a background terrain, you will need: Blender, the Blender Exporter Plugins and the QGIS. You'll find the download links in the [Resources](#resources) section.
If you're afraid of this task, please don't be. It's really simple and I've prepaired detailed step-by-step instructions for you, you'll find them in the separate README files. Here are the steps you need to follow:
diff --git a/maps4fs/generator/background.py b/maps4fs/generator/background.py
index ba4588b3..b22866e9 100644
--- a/maps4fs/generator/background.py
+++ b/maps4fs/generator/background.py
@@ -10,10 +10,14 @@
import trimesh # type: ignore
from maps4fs.generator.component import Component
-from maps4fs.generator.path_steps import DEFAULT_DISTANCE, get_steps
+from maps4fs.generator.path_steps import DEFAULT_DISTANCE, PATH_FULL_NAME, get_steps
from maps4fs.generator.tile import Tile
+from maps4fs.logger import timeit
RESIZE_FACTOR = 1 / 4
+SIMPLIFY_FACTOR = 10
+FULL_RESIZE_FACTOR = 1 / 4
+FULL_SIMPLIFY_FACTOR = 20
class Background(Component):
@@ -37,7 +41,12 @@ def preprocess(self) -> None:
# Getting a list of 8 tiles around the map starting from the N(North) tile.
for path_step in get_steps(self.map_height, self.map_width):
# Getting the destination coordinates for the current tile.
- tile_coordinates = path_step.get_destination(origin)
+ if path_step.angle is None:
+ # For the case when generating the overview map, which has the same
+ # center as the main map.
+ tile_coordinates = self.coordinates
+ else:
+ tile_coordinates = path_step.get_destination(origin)
# Create a Tile component, which is needed to save the DEM image.
tile = Tile(
@@ -129,29 +138,49 @@ def generate_obj_files(self) -> None:
self.logger.debug("Generating obj file for tile %s in path: %s", tile.code, save_path)
dem_data = cv2.imread(tile.dem_path, cv2.IMREAD_UNCHANGED) # pylint: disable=no-member
- self.plane_from_np(dem_data, save_path)
+ self.plane_from_np(tile.code, dem_data, save_path)
# pylint: disable=too-many-locals
- def plane_from_np(self, dem_data: np.ndarray, save_path: str) -> None:
+ @timeit
+ def plane_from_np(self, tile_code: str, dem_data: np.ndarray, save_path: str) -> None:
"""Generates a 3D obj file based on DEM data.
Arguments:
+ tile_code (str) -- The code of the tile.
dem_data (np.ndarray) -- The DEM data as a numpy array.
save_path (str) -- The path where the obj file will be saved.
"""
+ if tile_code == PATH_FULL_NAME:
+ resize_factor = FULL_RESIZE_FACTOR
+ simplify_factor = FULL_SIMPLIFY_FACTOR
+ self.logger.info("Generating a full map obj file")
+ else:
+ resize_factor = RESIZE_FACTOR
+ simplify_factor = SIMPLIFY_FACTOR
dem_data = cv2.resize( # pylint: disable=no-member
- dem_data, (0, 0), fx=RESIZE_FACTOR, fy=RESIZE_FACTOR
+ dem_data, (0, 0), fx=resize_factor, fy=resize_factor
)
self.logger.debug(
- "DEM data resized to shape: %s with factor: %s", dem_data.shape, RESIZE_FACTOR
+ "DEM data resized to shape: %s with factor: %s", dem_data.shape, resize_factor
)
+ # Invert the height values.
+ dem_data = dem_data.max() - dem_data
+
rows, cols = dem_data.shape
x = np.linspace(0, cols - 1, cols)
y = np.linspace(0, rows - 1, rows)
x, y = np.meshgrid(x, y)
z = dem_data
+ self.logger.info(
+ "Starting to generate a mesh for tile %s with shape: %s x %s. "
+ "This may take a while...",
+ tile_code,
+ cols,
+ rows,
+ )
+
vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
faces = []
@@ -162,15 +191,22 @@ def plane_from_np(self, dem_data: np.ndarray, save_path: str) -> None:
bottom_left = top_left + cols
bottom_right = bottom_left + 1
- # Invert the order of vertices to flip the normals
- faces.append([top_left, bottom_right, bottom_left])
- faces.append([top_left, top_right, bottom_right])
+ faces.append([top_left, bottom_left, bottom_right])
+ faces.append([top_left, bottom_right, top_right])
faces = np.array(faces) # type: ignore
mesh = trimesh.Trimesh(vertices=vertices, faces=faces)
+ # Apply rotation: 180 degrees around Y-axis and Z-axis
+ rotation_matrix_y = trimesh.transformations.rotation_matrix(np.pi, [0, 1, 0])
+ rotation_matrix_z = trimesh.transformations.rotation_matrix(np.pi, [0, 0, 1])
+ mesh.apply_transform(rotation_matrix_y)
+ mesh.apply_transform(rotation_matrix_z)
+
+ self.logger.info("Mesh generated with %s faces, will be simplified", len(mesh.faces))
+
# Simplify the mesh to reduce the number of faces.
- mesh = mesh.simplify_quadric_decimation(face_count=len(faces) // 10)
+ mesh = mesh.simplify_quadric_decimation(face_count=len(faces) // simplify_factor)
self.logger.debug("Mesh simplified to %s faces", len(mesh.faces))
mesh.export(save_path)
diff --git a/maps4fs/generator/map.py b/maps4fs/generator/map.py
index cfed2c00..2b791bc3 100644
--- a/maps4fs/generator/map.py
+++ b/maps4fs/generator/map.py
@@ -42,7 +42,7 @@ def __init__( # pylint: disable=R0917
self.map_directory = map_directory
if not logger:
- logger = Logger(__name__, to_stdout=True, to_file=False)
+ logger = Logger(to_stdout=True, to_file=False)
self.logger = logger
self.logger.debug("Game was set to %s", game.code)
diff --git a/maps4fs/generator/path_steps.py b/maps4fs/generator/path_steps.py
index 8677d9e7..f2b7953a 100644
--- a/maps4fs/generator/path_steps.py
+++ b/maps4fs/generator/path_steps.py
@@ -5,6 +5,7 @@
from geopy.distance import distance # type: ignore
DEFAULT_DISTANCE = 2048
+PATH_FULL_NAME = "FULL"
class PathStep(NamedTuple):
@@ -13,13 +14,17 @@ class PathStep(NamedTuple):
Attributes:
code {str} -- Tile code (N, NE, E, SE, S, SW, W, NW).
angle {int} -- Angle in degrees (for example 0 for North, 90 for East).
+ If None, the step is a full map with a center at the same coordinates as the
+ map itself.
distance {int} -- Distance in meters from previous step.
+ If None, the step is a full map with a center at the same coordinates as the
+ map itself.
size {tuple[int, int]} -- Size of the tile in pixels (width, height).
"""
code: str
- angle: int
- distance: int
+ angle: int | None
+ distance: int | None
size: tuple[int, int]
def get_destination(self, origin: tuple[float, float]) -> tuple[float, float]:
@@ -69,4 +74,10 @@ def get_steps(map_height: int, map_width: int) -> list[PathStep]:
PathStep(
"NW", 0, half_height + half_default_distance, (DEFAULT_DISTANCE, DEFAULT_DISTANCE)
),
+ PathStep(
+ PATH_FULL_NAME,
+ None,
+ None,
+ (map_width + DEFAULT_DISTANCE * 2, map_height + DEFAULT_DISTANCE * 2),
+ ),
]
diff --git a/maps4fs/logger.py b/maps4fs/logger.py
index 0c8f8513..22982b14 100644
--- a/maps4fs/logger.py
+++ b/maps4fs/logger.py
@@ -4,8 +4,11 @@
import os
import sys
from datetime import datetime
-from typing import Literal
+from logging import getLogger
+from time import perf_counter
+from typing import Any, Callable, Literal
+LOGGER_NAME = "maps4fs"
log_directory = os.path.join(os.getcwd(), "logs")
os.makedirs(log_directory, exist_ok=True)
@@ -15,12 +18,11 @@ class Logger(logging.Logger):
def __init__(
self,
- name: str,
level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "ERROR",
to_stdout: bool = True,
to_file: bool = True,
):
- super().__init__(name)
+ super().__init__(LOGGER_NAME)
self.setLevel(level)
self.stdout_handler = logging.StreamHandler(sys.stdout)
self.file_handler = logging.FileHandler(
@@ -44,3 +46,25 @@ def log_file(self) -> str:
today = datetime.now().strftime("%Y-%m-%d")
log_file = os.path.join(log_directory, f"{today}.txt")
return log_file
+
+
+def timeit(func: Callable[..., Any]) -> Callable[..., Any]:
+ """Decorator to log the time taken by a function to execute.
+
+ Args:
+ func (function): The function to be timed.
+
+ Returns:
+ function: The timed function.
+ """
+
+ def timed(*args, **kwargs):
+ logger = getLogger("maps4fs")
+ start = perf_counter()
+ result = func(*args, **kwargs)
+ end = perf_counter()
+ if logger is not None:
+ logger.info("Function %s took %s seconds to execute", func.__name__, end - start)
+ return result
+
+ return timed
diff --git a/pyproject.toml b/pyproject.toml
index d6835c79..480a9748 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "maps4fs"
-version = "0.9.2"
+version = "0.9.3"
description = "Generate map templates for Farming Simulator from real places."
authors = [{name = "iwatkot", email = "iwatkot@gmail.com"}]
license = {text = "MIT License"}
diff --git a/tutorials/README_satellite_images.md b/tutorials/README_satellite_images.md
index ad481689..17f3b9e1 100644
--- a/tutorials/README_satellite_images.md
+++ b/tutorials/README_satellite_images.md
@@ -87,7 +87,25 @@ So, set the path to the directory, remember that if you copy-paste it from the W
15. After the download is finished, all the images will be saved in the specified directory. You can now close the QGIS software.
➡️ Pay attention to the fact that your images will contain small lines from bounding boxes, you need to crop them in the image editor. before using them as textures, you also need to resize them (make sure that proportions are preserved, for example 4096x2048, 2048x2048, etc.) and convert them to the `.png` or `.dds` format.
-*️⃣ This approach does not guarantee that the map itself will be perfectly aligned with the background images as well as with the overview map, so you may need to adjust bounding boxes. You may consider those bounding boxes as a reference to help you get the right images, but you should not rely on them completely.
+*️⃣ This approach does not guarantee that the map itself will be perfectly aligned with the background images as well as with the overview map, so you may need to adjust bounding boxes. You may consider those bounding boxes as a reference to help you get the right images, but you should not rely on them completely.
+
+If you want to images match the map perfectly, you may need to download the images with a small `EXTENT_BUFFER` and then manually adjust them in an image editor. To do it, change the value in the rasterize script:
+
+```python
+processing.run(
+ "native:rasterize",
+ {
+ "EXTENT": epsg3857_string,
+ "EXTENT_BUFFER": 0, # ⬅️ Make this value higher.
+ "TILE_SIZE": 64,
+ "MAP_UNITS_PER_PIXEL": 1,
+ "MAKE_BACKGROUND_TRANSPARENT": False,
+ "MAP_THEME": None,
+ "LAYERS": None,
+ "OUTPUT": file_path,
+ },
+)
+```
⚠️ Below is an outdated method of manual downloading of images. It's highly recommended to use automatic scripts, that were generated by the software, but if you still want to do it manually, you can follow the steps below.
diff --git a/webui/webui.py b/webui/webui.py
index 31ce4cc9..ed811f7a 100644
--- a/webui/webui.py
+++ b/webui/webui.py
@@ -37,7 +37,7 @@ class Maps4FS:
def __init__(self):
self.download_path = None
- self.logger = mfs.Logger(__name__, level="DEBUG", to_file=False)
+ self.logger = mfs.Logger(level="DEBUG", to_file=False)
self.community = config.is_on_community_server()
self.logger.info("The application launched on the community server: %s", self.community)