Skip to content

Commit

Permalink
Splines generation and expert mode
Browse files Browse the repository at this point in the history
* Added splines generation.

* Linter updates.

* Z values for splines.

* Splines interpolation.

* Background terrain resize factor.

* WebUI updates.

* Expert mode.

* Config update.

* README update.
  • Loading branch information
iwatkot authored Dec 29, 2024
1 parent dcf115c commit 2f544af
Show file tree
Hide file tree
Showing 15 changed files with 558 additions and 221 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
🌿 Automatically generates decorative foliage 🆕<br>
🌲 Automatically generates forests 🆕<br>
🌊 Automatically generates water planes 🆕<br>
📈 Automatically generates splines 🆕<br>
🌍 Based on real-world data from OpenStreetMap<br>
🗺️ Supports [custom OSM maps](/docs/custom_osm.md)<br>
🏞️ Generates height map using SRTM dataset<br>
Expand All @@ -69,6 +70,8 @@
🌲 Automatically generates forests.<br><br>
<img src="https://github.com/user-attachments/assets/cce7d4e0-cba2-4dd2-b22d-03137fb2e860"><br>
🌊 Automatically generates water planes.<br><br>
<img src="https://github.com/user-attachments/assets/0b05b511-a595-48e7-a353-8298081314a4"><br>
📈 Automatically generates splines.<br><br>
<img src="https://github.com/user-attachments/assets/80e5923c-22c7-4dc0-8906-680902511f3a"><br>
🗒️ 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>
Expand Down Expand Up @@ -477,6 +480,12 @@ You can also apply some advanced settings to the map generation process. Note th

- Generate water - if enabled, the water planes obj files will be generated. You can turn it off if you already have those files or don't need them. By default, it's set to True.

- Resize factor - the factor by which the background terrain will be resized. In UI it sets as an integer number (default 8), will be converted to 1/8 (0.125). In expert mode use the float number. The higher the value, the smaller the background terrain will be. Warning: higher terrain will result long processing time and enormous file size.

## Splines Advanced settings

- Splines density - number of points, which will be added (interpolate) between each pair of existing points. The higher the value, the denser the spline will be. It can smooth the splines, but high values can in opposite make the splines look unnatural.

## 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
Binary file modified data/fs25-map-template.zip
Binary file not shown.
3 changes: 2 additions & 1 deletion dev/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ pyproj
trimesh
pympler
pydoc-markdown
streamlit-stl==0.0.4
streamlit-stl==0.0.5
pydantic
2 changes: 2 additions & 0 deletions maps4fs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
GRLESettings,
I3DSettings,
Map,
SettingsModel,
SplineSettings,
TextureSettings,
)
from maps4fs.logger import Logger
18 changes: 13 additions & 5 deletions maps4fs/generator/background.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from maps4fs.generator.texture import Texture

DEFAULT_DISTANCE = 2048
RESIZE_FACTOR = 1 / 8
FULL_NAME = "FULL"
FULL_PREVIEW_NAME = "PREVIEW"
ELEMENTS = [FULL_NAME, FULL_PREVIEW_NAME]
Expand Down Expand Up @@ -63,6 +62,7 @@ def preprocess(self) -> None:
os.path.join(self.background_directory, f"{name}.png") for name in ELEMENTS
]
self.not_substracted_path = os.path.join(self.background_directory, "not_substracted.png")
self.not_resized_path = os.path.join(self.background_directory, "not_resized.png")

dems = []

Expand Down Expand Up @@ -109,6 +109,7 @@ def process(self) -> None:
dem.process()
if not dem.is_preview: # type: ignore
shutil.copyfile(dem.dem_path, self.not_substracted_path)
self.cutout(dem.dem_path, save_path=self.not_resized_path)

if self.map.dem_settings.water_depth:
self.subtraction()
Expand Down Expand Up @@ -198,11 +199,12 @@ def generate_obj_files(self) -> None:
self.plane_from_np(dem_data, save_path, is_preview=dem.is_preview) # type: ignore

# pylint: disable=too-many-locals
def cutout(self, dem_path: str) -> str:
def cutout(self, dem_path: str, save_path: str | None = None) -> str:
"""Cuts out the center of the DEM (the actual map) and saves it as a separate file.
Arguments:
dem_path (str): The path to the DEM file.
save_path (str, optional): The path where the cutout DEM file will be saved.
Returns:
str -- The path to the cutout DEM file.
Expand All @@ -217,6 +219,11 @@ def cutout(self, dem_path: str) -> str:
y2 = center[1] + half_size
dem_data = dem_data[x1:x2, y1:y2]

if save_path:
cv2.imwrite(save_path, dem_data) # pylint: disable=no-member
self.logger.debug("Not resized DEM saved: %s", save_path)
return save_path

output_size = self.map_size + 1

main_dem_path = self.game.dem_file_path(self.map_directory)
Expand Down Expand Up @@ -252,11 +259,12 @@ def plane_from_np(
is_preview (bool, optional) -- If True, the preview mesh will be generated.
include_zeros (bool, optional) -- If True, the mesh will include the zero height values.
"""
resize_factor = self.map.background_settings.resize_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.
Expand Down Expand Up @@ -322,7 +330,7 @@ def plane_from_np(
else:
z_scaling_factor = 1 / 2**5
self.logger.debug("Z scaling factor: %s", z_scaling_factor)
mesh.apply_scale([1 / RESIZE_FACTOR, 1 / RESIZE_FACTOR, z_scaling_factor])
mesh.apply_scale([1 / resize_factor, 1 / resize_factor, z_scaling_factor])

mesh.export(save_path)
self.logger.debug("Obj file saved: %s", save_path)
Expand Down
93 changes: 71 additions & 22 deletions maps4fs/generator/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
from copy import deepcopy
from typing import TYPE_CHECKING, Any

import cv2
import cv2 # type: ignore
import osmnx as ox # type: ignore
from pyproj import Transformer
from shapely.affinity import rotate, translate # type: ignore
from shapely.geometry import Polygon, box # type: ignore
from shapely.geometry import LineString, Polygon, box # type: ignore

from maps4fs.generator.qgis import save_scripts

Expand Down Expand Up @@ -338,62 +338,79 @@ def top_left_coordinates_to_center(self, top_left: tuple[int, int]) -> tuple[int
return cs_x, cs_y

# pylint: disable=R0914
def fit_polygon_into_bounds(
self, polygon_points: list[tuple[int, int]], margin: int = 0, angle: int = 0
def fit_object_into_bounds(
self,
polygon_points: list[tuple[int, int]] | None = None,
linestring_points: list[tuple[int, int]] | None = None,
margin: int = 0,
angle: 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.
linestring_points (list[tuple[int, int]]): The points of the linestring.
margin (int, optional): The margin to add to the polygon. Defaults to 0.
angle (int, optional): The angle to rotate the polygon by. Defaults to 0.
Returns:
list[tuple[int, int]]: The points of the polygon fitted into the map bounds.
"""
if polygon_points is None and linestring_points is None:
raise ValueError("Either polygon or linestring points must be provided.")

min_x = min_y = 0
max_x = max_y = self.map_size

polygon = Polygon(polygon_points)
object_type = Polygon if polygon_points else LineString

# polygon = Polygon(polygon_points)
osm_object = object_type(polygon_points or linestring_points)

if angle:
center_x = center_y = self.map_rotated_size // 2
self.logger.debug(
"Rotating the polygon by %s degrees with center at %sx%s",
"Rotating the osm_object by %s degrees with center at %sx%s",
angle,
center_x,
center_y,
)
polygon = rotate(polygon, -angle, origin=(center_x, center_y))
osm_object = rotate(osm_object, -angle, origin=(center_x, center_y))
offset = (self.map_size / 2) - (self.map_rotated_size / 2)
self.logger.debug("Translating the polygon by %s", offset)
polygon = translate(polygon, xoff=offset, yoff=offset)
self.logger.debug("Rotated and translated polygon.")
self.logger.debug("Translating the osm_object by %s", offset)
osm_object = translate(osm_object, xoff=offset, yoff=offset)
self.logger.debug("Rotated and translated the osm_object.")

if margin:
polygon = polygon.buffer(margin, join_style="mitre")
if polygon.is_empty:
raise ValueError("The polygon is empty after adding the margin.")
if margin and object_type is Polygon:
osm_object = osm_object.buffer(margin, join_style="mitre")
if osm_object.is_empty:
raise ValueError("The osm_object is empty after adding the margin.")

# 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
# Intersect the osm_object with the bounds to fit it within the map
try:
fitted_polygon = polygon.intersection(bounds)
self.logger.debug("Fitted the polygon into the bounds: %s", bounds)
fitted_osm_object = osm_object.intersection(bounds)
self.logger.debug("Fitted the osm_object into the bounds: %s", bounds)
except Exception as e:
raise ValueError( # pylint: disable=W0707
f"Could not fit the polygon into the bounds: {e}"
f"Could not fit the osm_object into the bounds: {e}"
)

if not isinstance(fitted_polygon, Polygon):
raise ValueError("The fitted polygon is not a valid polygon.")
if not isinstance(fitted_osm_object, object_type):
raise ValueError("The fitted osm_object is not valid (probably splitted into parts).")

# Return the fitted polygon points
as_list = list(fitted_polygon.exterior.coords)
if object_type is Polygon:
as_list = list(fitted_osm_object.exterior.coords)
elif object_type is LineString:
as_list = list(fitted_osm_object.coords)
else:
raise ValueError("The object type is not supported.")

if not as_list:
raise ValueError("The fitted polygon has no points.")
raise ValueError("The fitted osm_object has no points.")
return as_list

def get_infolayer_path(self, layer_name: str) -> str | None:
Expand Down Expand Up @@ -476,3 +493,35 @@ def rotate_image(
self.logger.debug("Shape of the cropped image: %s", cropped.shape)

cv2.imwrite(output_path, cropped)

@staticmethod
def interpolate_points(
polyline: list[tuple[int, int]], num_points: int = 4
) -> list[tuple[int, int]]:
"""Receives a list of tuples, which represents a polyline. Add additional points
between the existing points to make the polyline smoother.
Arguments:
polyline (list[tuple[int, int]]): The list of points to interpolate.
num_points (int): The number of additional points to add between each pair of points.
Returns:
list[tuple[int, int]]: The list of points with additional points.
"""
if not polyline or num_points < 1:
return polyline

interpolated_polyline = []
for i in range(len(polyline) - 1):
p1 = polyline[i]
p2 = polyline[i + 1]
interpolated_polyline.append(p1)
for j in range(1, num_points + 1):
new_point = (
p1[0] + (p2[0] - p1[0]) * j / (num_points + 1),
p1[1] + (p2[1] - p1[1]) * j / (num_points + 1),
)
interpolated_polyline.append((int(new_point[0]), int(new_point[1])))
interpolated_polyline.append(polyline[-1])

return interpolated_polyline
2 changes: 1 addition & 1 deletion maps4fs/generator/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class Game:
_tree_schema: str | None = None

# Order matters! Some components depend on others.
components = [Texture, I3d, GRLE, Background, Config]
components = [Texture, GRLE, Background, I3d, Config]

def __init__(self, map_template_path: str | None = None):
if map_template_path:
Expand Down
6 changes: 4 additions & 2 deletions maps4fs/generator/grle.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,10 @@ def _add_farmlands(self) -> None:

for field in fields:
try:
fitted_field = self.fit_polygon_into_bounds(
field, self.map.grle_settings.farmland_margin, angle=self.rotation
fitted_field = self.fit_object_into_bounds(
polygon_points=field,
margin=self.map.grle_settings.farmland_margin,
angle=self.rotation,
)
except ValueError as e:
self.logger.warning(
Expand Down
Loading

0 comments on commit 2f544af

Please sign in to comment.