Skip to content

Commit

Permalink
Automatic generation of farmlands
Browse files Browse the repository at this point in the history
* Removed placeables.

* GDM schema.

* Adding farmlands.

* Linter updates.

* Advanced settings for farmland margin.

* Adding farmlands to XML.

* Linter updates.

* Docs update.

* README update.
  • Loading branch information
iwatkot authored Dec 18, 2024
1 parent fb52a83 commit 6cdc900
Show file tree
Hide file tree
Showing 10 changed files with 260 additions and 88 deletions.
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@
🚜 Supports Farming Simulator 22 and 25<br>
🔷 Generates *.obj files for background terrain based on the real-world height map<br>
📄 Generates scripts to download high-resolution satellite images from [QGIS](https://qgis.org/download/) in one click<br>
🧰 Modder Toolbox to help you with various tasks 🆕<br>
🧰 Modder Toolbox to help you with various tasks <br>
🌾 Automatically generates fields 🆕<br>
🌽 Automatically generates farmlands 🆕<br>

<p align="center">
<img src="https://github.com/user-attachments/assets/cf8f5752-9c69-4018-bead-290f59ba6976"><br>
Expand All @@ -56,13 +57,15 @@
🗒️ True-to-life blueprints for fast and precise modding.<br><br>
<img width="480" src="https://github.com/user-attachments/assets/1a8802d2-6a3b-4bfa-af2b-7c09478e199b"><br>
🌾 Field generation with one click.<br><br>
<img width="480" src="https://github.com/user-attachments/assets/4d1fa879-5d60-438b-a84e-16883bcef0ec"><br>
🌽 Automatic farmlands generation based on the fields.<br><br>
<img src="https://github.com/user-attachments/assets/cce45575-c917-4a1b-bdc0-6368e32ccdff"><br>
📏 Almost any possible map sizes.
</p>

📹 A complete step-by-step video tutorial is here!
<a href="https://www.youtube.com/watch?v=Nl_aqXJ5nAk" target="_blank"><img src="https://github.com/user-attachments/assets/4845e030-0e73-47ab-a5a3-430308913060"/></a>
<i>How to Generate a Map for Farming Simulator 25 and 22 from a real place using maps4FS</i>
<p align="center"><i>How to Generate a Map for Farming Simulator 25 and 22 from a real place using maps4FS.</i></p>

## 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.<br>
Expand Down Expand Up @@ -447,6 +450,10 @@ 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.

### 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.

## Resources
In this section, you'll find a list of the resources that you need to create a map for the Farming Simulator.<br>
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.<br>
Expand Down
42 changes: 42 additions & 0 deletions data/fs25-grle-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,47 @@
"width_multiplier": 2.0,
"channels": 1,
"data_type": "uint8"
},
{
"name": "densityMap_fruits.png",
"height_multiplier": 2.0,
"width_multiplier": 2.0,
"channels": 3,
"data_type": "uint8"
},
{
"name": "densityMap_ground.png",
"height_multiplier": 2.0,
"width_multiplier": 2.0,
"channels": 3,
"data_type": "uint8"
},
{
"name": "densityMap_groundFoliage.png",
"height_multiplier": 1.0,
"width_multiplier": 1.0,
"channels": 1,
"data_type": "uint8"
},
{
"name": "densityMap_height.png",
"height_multiplier": 2.0,
"width_multiplier": 2.0,
"channels": 3,
"data_type": "uint8"
},
{
"name": "densityMap_stones.png",
"height_multiplier": 2.0,
"width_multiplier": 2.0,
"channels": 1,
"data_type": "uint8"
},
{
"name": "densityMap_weed.png",
"height_multiplier": 2.0,
"width_multiplier": 2.0,
"channels": 1,
"data_type": "uint8"
}
]
Binary file modified data/fs25-map-template.zip
Binary file not shown.
3 changes: 2 additions & 1 deletion docs/farmlands.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## Farmlands

It's a pretty simple component of the map, but it's also one of the most important. If you do not define the farmlands InfoLayer in Giants Editor, you will not be able to buy any land in the game. But, lucky for us, it's simple and straightforward to set up.
It's a pretty simple component of the map, but it's also one of the most important. If you do not define the farmlands InfoLayer in Giants Editor, you will not be able to buy any land in the game. But, lucky for us, it's simple and straightforward to set up.
➡️ The generator automatically draws farmlands and adds them to the `farmlands.xml` file. But, if you need to adjust them, you can do it manually.

### Setting up the Farmlands InfoLayer

Expand Down
2 changes: 1 addition & 1 deletion docs/step_by_step.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ By default, the generator will add all the fields from the [OpenStreetMap](https
ℹ️ Learn more about it in the [Fields](https://github.com/iwatkot/maps4fs/blob/main/docs/fields.md) document.

### 10. 📚 Add farmlands
This one is pretty simple, you can just paint them in the Giants Editor.
The generator will automatically add farmlands to the map. But if you need, you can add or adjust them manually in the Giants Editor.
ℹ️ Learn more about it in the [Farmlands](https://github.com/iwatkot/maps4fs/blob/main/docs/farmlands.md) document.

So, that's it! Now, you can actually start creating your own map. Mostly, you need to add buildings, roads, and other objects to make it look like a real map. And the painted textures will help you to place them correctly, just like in the real world.
Expand Down
97 changes: 96 additions & 1 deletion maps4fs/generator/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@

import osmnx as ox # type: ignore
from pyproj import Transformer
from shapely.geometry import Polygon, box # type: ignore

from maps4fs.generator.qgis import save_scripts

if TYPE_CHECKING:
from maps4fs.generator.game import Game


# pylint: disable=R0801, R0903, R0902
# pylint: disable=R0801, R0903, R0902, R0904
class Component:
"""Base class for all map generation components.
Expand Down Expand Up @@ -281,3 +282,97 @@ def create_qgis_scripts(
"""
class_name = self.__class__.__name__.lower()
save_scripts(qgis_layers, class_name, self.scripts_directory)

def get_polygon_center(self, polygon_points: list[tuple[int, int]]) -> tuple[int, int]:
"""Calculates the center of a polygon defined by a list of points.
Arguments:
polygon_points (list[tuple[int, int]]): The points of the polygon.
Returns:
tuple[int, int]: The center of the polygon.
"""
polygon = Polygon(polygon_points)
center = polygon.centroid
return int(center.x), int(center.y)

def absolute_to_relative(
self, point: tuple[int, int], center: tuple[int, int]
) -> tuple[int, int]:
"""Converts a pair of absolute coordinates to relative coordinates.
Arguments:
point (tuple[int, int]): The absolute coordinates.
center (tuple[int, int]): The center coordinates.
Returns:
tuple[int, int]: The relative coordinates.
"""
cx, cy = center
x, y = point
return x - cx, y - cy

def top_left_coordinates_to_center(self, top_left: tuple[int, int]) -> tuple[int, int]:
"""Converts a pair of coordinates from the top-left system to the center system.
In top-left system, the origin (0, 0) is in the top-left corner of the map, while in the
center system, the origin is in the center of the map.
Arguments:
top_left (tuple[int, int]): The coordinates in the top-left system.
Returns:
tuple[int, int]: The coordinates in the center system.
"""
x, y = top_left
cs_x = x - self.map_width // 2
cs_y = y - self.map_height // 2

return cs_x, cs_y

def fit_polygon_into_bounds(
self, polygon_points: list[tuple[int, int]], margin: int = 0
) -> list[tuple[int, int]]:
"""Fits a polygon into the bounds of the map.
Arguments:
polygon_points (list[tuple[int, int]]): The points of the polygon.
margin (int, optional): The margin to add to the polygon. Defaults to 0.
Returns:
list[tuple[int, int]]: The points of the polygon fitted into the map bounds.
"""
min_x = min_y = 0
max_x, max_y = self.map_width, self.map_height

# Create a polygon from the given points
polygon = Polygon(polygon_points)

if margin:
polygon = polygon.buffer(margin, join_style="mitre")

# Create a bounding box for the map bounds
bounds = box(min_x, min_y, max_x, max_y)

# Intersect the polygon with the bounds to fit it within the map
fitted_polygon = polygon.intersection(bounds)

if not isinstance(fitted_polygon, Polygon):
raise ValueError("The fitted polygon is not a valid polygon.")

# Return the fitted polygon points
return list(fitted_polygon.exterior.coords)

def get_infolayer_path(self, layer_name: str) -> str | None:
"""Returns the path to the info layer file.
Arguments:
layer_name (str): The name of the layer.
Returns:
str | None: The path to the info layer file or None if the layer does not exist.
"""
info_layer_path = os.path.join(self.info_layers_directory, f"{layer_name}.json")
if not os.path.isfile(info_layer_path):
self.logger.warning("Info layer %s does not exist", info_layer_path)
return None
return info_layer_path
82 changes: 81 additions & 1 deletion maps4fs/generator/grle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import os
from xml.etree import ElementTree as ET

import cv2
import numpy as np
Expand All @@ -27,6 +28,9 @@ class GRLE(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.farmland_margin = self.kwargs.get("farmland_margin", 0)

try:
grle_schema_path = self.game.grle_schema
except ValueError:
Expand Down Expand Up @@ -55,15 +59,22 @@ def process(self) -> None:

height = int(self.map_height * info_layer["height_multiplier"])
width = int(self.map_width * info_layer["width_multiplier"])
channels = info_layer["channels"]
data_type = info_layer["data_type"]

# Create the InfoLayer PNG file with zeros.
info_layer_data = np.zeros((height, width), dtype=data_type)
if channels == 1:
info_layer_data = np.zeros((height, width), dtype=data_type)
else:
info_layer_data = np.zeros((height, width, channels), dtype=data_type)
self.logger.debug("Shape of %s: %s.", info_layer["name"], info_layer_data.shape)
cv2.imwrite(file_path, info_layer_data) # pylint: disable=no-member
self.logger.debug("InfoLayer PNG file %s created.", file_path)
else:
self.logger.warning("Invalid InfoLayer schema: %s.", info_layer)

self._add_farmlands()

def previews(self) -> list[str]:
"""Returns a list of paths to the preview images (empty list).
The component does not generate any preview images so it returns an empty list.
Expand All @@ -72,3 +83,72 @@ def previews(self) -> list[str]:
list[str]: An empty list.
"""
return []

# pylint: disable=R0801, R0914
def _add_farmlands(self) -> None:
"""Adds farmlands to the InfoLayer PNG file."""

textures_info_layer_path = self.get_infolayer_path("textures")
if not textures_info_layer_path:
return

with open(textures_info_layer_path, "r", encoding="utf-8") as textures_info_layer_file:
textures_info_layer = json.load(textures_info_layer_file)

fields: list[list[tuple[int, int]]] | None = textures_info_layer.get("fields")
if not fields:
self.logger.warning("Fields data not found in textures info layer.")
return

self.logger.info("Found %s fields in textures info layer.", len(fields))

info_layer_farmlands_path = os.path.join(
self.game.weights_dir_path(self.map_directory), "infoLayer_farmlands.png"
)

if not os.path.isfile(info_layer_farmlands_path):
self.logger.warning("InfoLayer PNG file %s not found.", info_layer_farmlands_path)
return

# pylint: disable=no-member
image = cv2.imread(info_layer_farmlands_path, cv2.IMREAD_UNCHANGED)
farmlands_xml_path = os.path.join(self.map_directory, "map/config/farmlands.xml")
if not os.path.isfile(farmlands_xml_path):
self.logger.warning("Farmlands XML file %s not found.", farmlands_xml_path)
return

tree = ET.parse(farmlands_xml_path)
farmlands_xml = tree.find("farmlands")

for field_id, field in enumerate(fields, start=1):
try:
fitted_field = self.fit_polygon_into_bounds(field, self.farmland_margin)
except ValueError as e:
self.logger.warning("Field %s could not be fitted into the map bounds.", field_id)
self.logger.debug("Error: %s", e)
continue

field_np = np.array(fitted_field, np.int32)
field_np = field_np.reshape((-1, 1, 2))

# Infolayer image is 1/2 of the size of the map image, that's why we need to divide
# the coordinates by 2.
field_np = field_np // 2

# pylint: disable=no-member
cv2.fillPoly(image, [field_np], field_id) # type: ignore

# Add the field to the farmlands XML.
farmland = ET.SubElement(farmlands_xml, "farmland") # type: ignore
farmland.set("id", str(field_id))
farmland.set("priceScale", "1")
farmland.set("npcName", "FORESTER")

tree.write(farmlands_xml_path)

self.logger.info("Farmlands added to the farmlands XML file: %s.", farmlands_xml_path)

cv2.imwrite(info_layer_farmlands_path, image) # pylint: disable=no-member
self.logger.info(
"Farmlands added to the InfoLayer PNG file: %s.", info_layer_farmlands_path
)
Loading

0 comments on commit 6cdc900

Please sign in to comment.