Skip to content

Commit

Permalink
Generating full background in one file and *.obj orientation fix
Browse files Browse the repository at this point in the history
* Generating full background terrain as one file.

* README update.

* Tutorials update.

* README update.
  • Loading branch information
iwatkot authored Nov 30, 2024
1 parent 0236ae7 commit ab2df99
Show file tree
Hide file tree
Showing 8 changed files with 114 additions and 19 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br>
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.<br>
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.<br>

![Complete background terrain in Blender](https://github.com/user-attachments/assets/7266b8f1-bfa2-4c14-a740-1c84b1030a66)

➡️ *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.<br>

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:
Expand Down
56 changes: 46 additions & 10 deletions maps4fs/generator/background.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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(
Expand Down Expand Up @@ -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 = []

Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion maps4fs/generator/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
15 changes: 13 additions & 2 deletions maps4fs/generator/path_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from geopy.distance import distance # type: ignore

DEFAULT_DISTANCE = 2048
PATH_FULL_NAME = "FULL"


class PathStep(NamedTuple):
Expand All @@ -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]:
Expand Down Expand Up @@ -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),
),
]
30 changes: 27 additions & 3 deletions maps4fs/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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(
Expand All @@ -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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
20 changes: 19 additions & 1 deletion tutorials/README_satellite_images.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br>
➡️ 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.<br>

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.<br>
<details>
Expand Down
2 changes: 1 addition & 1 deletion webui/webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit ab2df99

Please sign in to comment.