maps4fs 1.3.6__py3-none-any.whl → 1.3.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
maps4fs/__init__.py CHANGED
@@ -6,6 +6,8 @@ from maps4fs.generator.map import (
6
6
  GRLESettings,
7
7
  I3DSettings,
8
8
  Map,
9
+ SettingsModel,
10
+ SplineSettings,
9
11
  TextureSettings,
10
12
  )
11
13
  from maps4fs.logger import Logger
@@ -17,7 +17,6 @@ from maps4fs.generator.dem import DEM
17
17
  from maps4fs.generator.texture import Texture
18
18
 
19
19
  DEFAULT_DISTANCE = 2048
20
- RESIZE_FACTOR = 1 / 8
21
20
  FULL_NAME = "FULL"
22
21
  FULL_PREVIEW_NAME = "PREVIEW"
23
22
  ELEMENTS = [FULL_NAME, FULL_PREVIEW_NAME]
@@ -63,6 +62,7 @@ class Background(Component):
63
62
  os.path.join(self.background_directory, f"{name}.png") for name in ELEMENTS
64
63
  ]
65
64
  self.not_substracted_path = os.path.join(self.background_directory, "not_substracted.png")
65
+ self.not_resized_path = os.path.join(self.background_directory, "not_resized.png")
66
66
 
67
67
  dems = []
68
68
 
@@ -109,6 +109,7 @@ class Background(Component):
109
109
  dem.process()
110
110
  if not dem.is_preview: # type: ignore
111
111
  shutil.copyfile(dem.dem_path, self.not_substracted_path)
112
+ self.cutout(dem.dem_path, save_path=self.not_resized_path)
112
113
 
113
114
  if self.map.dem_settings.water_depth:
114
115
  self.subtraction()
@@ -198,11 +199,12 @@ class Background(Component):
198
199
  self.plane_from_np(dem_data, save_path, is_preview=dem.is_preview) # type: ignore
199
200
 
200
201
  # pylint: disable=too-many-locals
201
- def cutout(self, dem_path: str) -> str:
202
+ def cutout(self, dem_path: str, save_path: str | None = None) -> str:
202
203
  """Cuts out the center of the DEM (the actual map) and saves it as a separate file.
203
204
 
204
205
  Arguments:
205
206
  dem_path (str): The path to the DEM file.
207
+ save_path (str, optional): The path where the cutout DEM file will be saved.
206
208
 
207
209
  Returns:
208
210
  str -- The path to the cutout DEM file.
@@ -217,6 +219,11 @@ class Background(Component):
217
219
  y2 = center[1] + half_size
218
220
  dem_data = dem_data[x1:x2, y1:y2]
219
221
 
222
+ if save_path:
223
+ cv2.imwrite(save_path, dem_data) # pylint: disable=no-member
224
+ self.logger.debug("Not resized DEM saved: %s", save_path)
225
+ return save_path
226
+
220
227
  output_size = self.map_size + 1
221
228
 
222
229
  main_dem_path = self.game.dem_file_path(self.map_directory)
@@ -252,11 +259,12 @@ class Background(Component):
252
259
  is_preview (bool, optional) -- If True, the preview mesh will be generated.
253
260
  include_zeros (bool, optional) -- If True, the mesh will include the zero height values.
254
261
  """
262
+ resize_factor = self.map.background_settings.resize_factor
255
263
  dem_data = cv2.resize( # pylint: disable=no-member
256
- dem_data, (0, 0), fx=RESIZE_FACTOR, fy=RESIZE_FACTOR
264
+ dem_data, (0, 0), fx=resize_factor, fy=resize_factor
257
265
  )
258
266
  self.logger.debug(
259
- "DEM data resized to shape: %s with factor: %s", dem_data.shape, RESIZE_FACTOR
267
+ "DEM data resized to shape: %s with factor: %s", dem_data.shape, resize_factor
260
268
  )
261
269
 
262
270
  # Invert the height values.
@@ -322,7 +330,7 @@ class Background(Component):
322
330
  else:
323
331
  z_scaling_factor = 1 / 2**5
324
332
  self.logger.debug("Z scaling factor: %s", z_scaling_factor)
325
- mesh.apply_scale([1 / RESIZE_FACTOR, 1 / RESIZE_FACTOR, z_scaling_factor])
333
+ mesh.apply_scale([1 / resize_factor, 1 / resize_factor, z_scaling_factor])
326
334
 
327
335
  mesh.export(save_path)
328
336
  self.logger.debug("Obj file saved: %s", save_path)
@@ -7,11 +7,11 @@ import os
7
7
  from copy import deepcopy
8
8
  from typing import TYPE_CHECKING, Any
9
9
 
10
- import cv2
10
+ import cv2 # type: ignore
11
11
  import osmnx as ox # type: ignore
12
12
  from pyproj import Transformer
13
13
  from shapely.affinity import rotate, translate # type: ignore
14
- from shapely.geometry import Polygon, box # type: ignore
14
+ from shapely.geometry import LineString, Polygon, box # type: ignore
15
15
 
16
16
  from maps4fs.generator.qgis import save_scripts
17
17
 
@@ -338,62 +338,79 @@ class Component:
338
338
  return cs_x, cs_y
339
339
 
340
340
  # pylint: disable=R0914
341
- def fit_polygon_into_bounds(
342
- self, polygon_points: list[tuple[int, int]], margin: int = 0, angle: int = 0
341
+ def fit_object_into_bounds(
342
+ self,
343
+ polygon_points: list[tuple[int, int]] | None = None,
344
+ linestring_points: list[tuple[int, int]] | None = None,
345
+ margin: int = 0,
346
+ angle: int = 0,
343
347
  ) -> list[tuple[int, int]]:
344
348
  """Fits a polygon into the bounds of the map.
345
349
 
346
350
  Arguments:
347
351
  polygon_points (list[tuple[int, int]]): The points of the polygon.
352
+ linestring_points (list[tuple[int, int]]): The points of the linestring.
348
353
  margin (int, optional): The margin to add to the polygon. Defaults to 0.
349
354
  angle (int, optional): The angle to rotate the polygon by. Defaults to 0.
350
355
 
351
356
  Returns:
352
357
  list[tuple[int, int]]: The points of the polygon fitted into the map bounds.
353
358
  """
359
+ if polygon_points is None and linestring_points is None:
360
+ raise ValueError("Either polygon or linestring points must be provided.")
361
+
354
362
  min_x = min_y = 0
355
363
  max_x = max_y = self.map_size
356
364
 
357
- polygon = Polygon(polygon_points)
365
+ object_type = Polygon if polygon_points else LineString
366
+
367
+ # polygon = Polygon(polygon_points)
368
+ osm_object = object_type(polygon_points or linestring_points)
358
369
 
359
370
  if angle:
360
371
  center_x = center_y = self.map_rotated_size // 2
361
372
  self.logger.debug(
362
- "Rotating the polygon by %s degrees with center at %sx%s",
373
+ "Rotating the osm_object by %s degrees with center at %sx%s",
363
374
  angle,
364
375
  center_x,
365
376
  center_y,
366
377
  )
367
- polygon = rotate(polygon, -angle, origin=(center_x, center_y))
378
+ osm_object = rotate(osm_object, -angle, origin=(center_x, center_y))
368
379
  offset = (self.map_size / 2) - (self.map_rotated_size / 2)
369
- self.logger.debug("Translating the polygon by %s", offset)
370
- polygon = translate(polygon, xoff=offset, yoff=offset)
371
- self.logger.debug("Rotated and translated polygon.")
380
+ self.logger.debug("Translating the osm_object by %s", offset)
381
+ osm_object = translate(osm_object, xoff=offset, yoff=offset)
382
+ self.logger.debug("Rotated and translated the osm_object.")
372
383
 
373
- if margin:
374
- polygon = polygon.buffer(margin, join_style="mitre")
375
- if polygon.is_empty:
376
- raise ValueError("The polygon is empty after adding the margin.")
384
+ if margin and object_type is Polygon:
385
+ osm_object = osm_object.buffer(margin, join_style="mitre")
386
+ if osm_object.is_empty:
387
+ raise ValueError("The osm_object is empty after adding the margin.")
377
388
 
378
389
  # Create a bounding box for the map bounds
379
390
  bounds = box(min_x, min_y, max_x, max_y)
380
391
 
381
- # Intersect the polygon with the bounds to fit it within the map
392
+ # Intersect the osm_object with the bounds to fit it within the map
382
393
  try:
383
- fitted_polygon = polygon.intersection(bounds)
384
- self.logger.debug("Fitted the polygon into the bounds: %s", bounds)
394
+ fitted_osm_object = osm_object.intersection(bounds)
395
+ self.logger.debug("Fitted the osm_object into the bounds: %s", bounds)
385
396
  except Exception as e:
386
397
  raise ValueError( # pylint: disable=W0707
387
- f"Could not fit the polygon into the bounds: {e}"
398
+ f"Could not fit the osm_object into the bounds: {e}"
388
399
  )
389
400
 
390
- if not isinstance(fitted_polygon, Polygon):
391
- raise ValueError("The fitted polygon is not a valid polygon.")
401
+ if not isinstance(fitted_osm_object, object_type):
402
+ raise ValueError("The fitted osm_object is not valid (probably splitted into parts).")
392
403
 
393
404
  # Return the fitted polygon points
394
- as_list = list(fitted_polygon.exterior.coords)
405
+ if object_type is Polygon:
406
+ as_list = list(fitted_osm_object.exterior.coords)
407
+ elif object_type is LineString:
408
+ as_list = list(fitted_osm_object.coords)
409
+ else:
410
+ raise ValueError("The object type is not supported.")
411
+
395
412
  if not as_list:
396
- raise ValueError("The fitted polygon has no points.")
413
+ raise ValueError("The fitted osm_object has no points.")
397
414
  return as_list
398
415
 
399
416
  def get_infolayer_path(self, layer_name: str) -> str | None:
@@ -476,3 +493,35 @@ class Component:
476
493
  self.logger.debug("Shape of the cropped image: %s", cropped.shape)
477
494
 
478
495
  cv2.imwrite(output_path, cropped)
496
+
497
+ @staticmethod
498
+ def interpolate_points(
499
+ polyline: list[tuple[int, int]], num_points: int = 4
500
+ ) -> list[tuple[int, int]]:
501
+ """Receives a list of tuples, which represents a polyline. Add additional points
502
+ between the existing points to make the polyline smoother.
503
+
504
+ Arguments:
505
+ polyline (list[tuple[int, int]]): The list of points to interpolate.
506
+ num_points (int): The number of additional points to add between each pair of points.
507
+
508
+ Returns:
509
+ list[tuple[int, int]]: The list of points with additional points.
510
+ """
511
+ if not polyline or num_points < 1:
512
+ return polyline
513
+
514
+ interpolated_polyline = []
515
+ for i in range(len(polyline) - 1):
516
+ p1 = polyline[i]
517
+ p2 = polyline[i + 1]
518
+ interpolated_polyline.append(p1)
519
+ for j in range(1, num_points + 1):
520
+ new_point = (
521
+ p1[0] + (p2[0] - p1[0]) * j / (num_points + 1),
522
+ p1[1] + (p2[1] - p1[1]) * j / (num_points + 1),
523
+ )
524
+ interpolated_polyline.append((int(new_point[0]), int(new_point[1])))
525
+ interpolated_polyline.append(polyline[-1])
526
+
527
+ return interpolated_polyline
maps4fs/generator/game.py CHANGED
@@ -39,7 +39,7 @@ class Game:
39
39
  _tree_schema: str | None = None
40
40
 
41
41
  # Order matters! Some components depend on others.
42
- components = [Texture, I3d, GRLE, Background, Config]
42
+ components = [Texture, GRLE, Background, I3d, Config]
43
43
 
44
44
  def __init__(self, map_template_path: str | None = None):
45
45
  if map_template_path:
maps4fs/generator/grle.py CHANGED
@@ -144,8 +144,10 @@ class GRLE(Component):
144
144
 
145
145
  for field in fields:
146
146
  try:
147
- fitted_field = self.fit_polygon_into_bounds(
148
- field, self.map.grle_settings.farmland_margin, angle=self.rotation
147
+ fitted_field = self.fit_object_into_bounds(
148
+ polygon_points=field,
149
+ margin=self.map.grle_settings.farmland_margin,
150
+ angle=self.rotation,
149
151
  )
150
152
  except ValueError as e:
151
153
  self.logger.warning(
maps4fs/generator/i3d.py CHANGED
@@ -19,7 +19,8 @@ DISPLACEMENT_LAYER_SIZE_FOR_BIG_MAPS = 32768
19
19
  DEFAULT_MAX_LOD_DISTANCE = 10000
20
20
  DEFAULT_MAX_LOD_OCCLUDER_DISTANCE = 10000
21
21
  NODE_ID_STARTING_VALUE = 2000
22
- TREE_NODE_ID_STARTING_VALUE = 4000
22
+ SPLINES_NODE_ID_STARTING_VALUE = 5000
23
+ TREE_NODE_ID_STARTING_VALUE = 10000
23
24
  DEFAULT_FOREST_DENSITY = 10
24
25
 
25
26
 
@@ -56,6 +57,7 @@ class I3d(Component):
56
57
  self._add_fields()
57
58
  if self.game.code == "FS25":
58
59
  self._add_forests()
60
+ self._add_splines()
59
61
 
60
62
  def _get_tree(self) -> ET.ElementTree | None:
61
63
  """Returns the ElementTree instance of the map I3D file."""
@@ -129,6 +131,134 @@ class I3d(Component):
129
131
  """
130
132
  return []
131
133
 
134
+ # pylint: disable=R0914, R0915
135
+ def _add_splines(self) -> None:
136
+ """Adds splines to the map I3D file."""
137
+ splines_i3d_path = os.path.join(self.map_directory, "map", "splines.i3d")
138
+ if not os.path.isfile(splines_i3d_path):
139
+ self.logger.warning("Splines I3D file not found: %s.", splines_i3d_path)
140
+ return
141
+
142
+ tree = ET.parse(splines_i3d_path)
143
+
144
+ textures_info_layer_path = self.get_infolayer_path("textures")
145
+ if not textures_info_layer_path:
146
+ return
147
+
148
+ with open(textures_info_layer_path, "r", encoding="utf-8") as textures_info_layer_file:
149
+ textures_info_layer = json.load(textures_info_layer_file)
150
+
151
+ roads_polylines: list[list[tuple[int, int]]] | None = textures_info_layer.get(
152
+ "roads_polylines"
153
+ )
154
+
155
+ if not roads_polylines:
156
+ self.logger.warning("Roads polylines data not found in textures info layer.")
157
+ return
158
+
159
+ self.logger.info("Found %s roads polylines in textures info layer.", len(roads_polylines))
160
+ self.logger.debug("Starging to add roads polylines to the I3D file.")
161
+
162
+ root = tree.getroot()
163
+ # Find <Shapes> element in the I3D file.
164
+ shapes_node = root.find(".//Shapes")
165
+ # Find <Scene> element in the I3D file.
166
+ scene_node = root.find(".//Scene")
167
+
168
+ # Read the not resized DEM to obtain Z values for spline points.
169
+ background_component = self.map.get_component("Background")
170
+ if not background_component:
171
+ self.logger.warning("Background component not found.")
172
+ return
173
+
174
+ # pylint: disable=no-member
175
+ not_resized_dem = cv2.imread(
176
+ background_component.not_resized_path, cv2.IMREAD_UNCHANGED # type: ignore
177
+ )
178
+ self.logger.debug(
179
+ "Not resized DEM loaded from: %s. Shape: %s.",
180
+ background_component.not_resized_path, # type: ignore
181
+ not_resized_dem.shape,
182
+ )
183
+ dem_x_size, dem_y_size = not_resized_dem.shape
184
+
185
+ if shapes_node is not None and scene_node is not None:
186
+ node_id = SPLINES_NODE_ID_STARTING_VALUE
187
+
188
+ for road_id, road in enumerate(roads_polylines, start=1):
189
+ # Add to scene node
190
+ # <Shape name="spline01_CSV" translation="0 0 0" nodeId="11" shapeId="11"/>
191
+
192
+ try:
193
+ fitted_road = self.fit_object_into_bounds(
194
+ linestring_points=road, angle=self.rotation
195
+ )
196
+ except ValueError as e:
197
+ self.logger.warning(
198
+ "Road %s could not be fitted into the map bounds with error: %s",
199
+ road_id,
200
+ e,
201
+ )
202
+ continue
203
+
204
+ self.logger.debug("Road %s has %s points.", road_id, len(fitted_road))
205
+ fitted_road = self.interpolate_points(
206
+ fitted_road, num_points=self.map.spline_settings.spline_density
207
+ )
208
+ self.logger.debug(
209
+ "Road %s has %s points after interpolation.", road_id, len(fitted_road)
210
+ )
211
+
212
+ spline_name = f"spline{road_id}"
213
+
214
+ shape_node = ET.Element("Shape")
215
+ shape_node.set("name", spline_name)
216
+ shape_node.set("translation", "0 0 0")
217
+ shape_node.set("nodeId", str(node_id))
218
+ shape_node.set("shapeId", str(node_id))
219
+
220
+ scene_node.append(shape_node)
221
+
222
+ road_ccs = [self.top_left_coordinates_to_center(point) for point in fitted_road]
223
+
224
+ # Add to shapes node
225
+ # <NurbsCurve name="spline01_CSV" shapeId="11" degree="3" form="open">
226
+
227
+ nurbs_curve_node = ET.Element("NurbsCurve")
228
+ nurbs_curve_node.set("name", spline_name)
229
+ nurbs_curve_node.set("shapeId", str(node_id))
230
+ nurbs_curve_node.set("degree", "3")
231
+ nurbs_curve_node.set("form", "open")
232
+
233
+ # Now for each point in the road add the following entry to nurbs_curve_node
234
+ # <cv c="-224.548401, 427.297546, -2047.570312" />
235
+ # The second coordinate (Z) will be 0 at the moment.
236
+
237
+ for point_ccs, point in zip(road_ccs, fitted_road):
238
+ cx, cy = point_ccs
239
+ x, y = point
240
+
241
+ x = int(x)
242
+ y = int(y)
243
+
244
+ x = max(0, min(x, dem_x_size - 1))
245
+ y = max(0, min(y, dem_y_size - 1))
246
+
247
+ z = not_resized_dem[y, x]
248
+ z /= 32 # Yes, it's a magic number here.
249
+
250
+ cv_node = ET.Element("cv")
251
+ cv_node.set("c", f"{cx}, {z}, {cy}")
252
+
253
+ nurbs_curve_node.append(cv_node)
254
+
255
+ shapes_node.append(nurbs_curve_node)
256
+
257
+ node_id += 1
258
+
259
+ tree.write(splines_i3d_path) # type: ignore
260
+ self.logger.debug("Splines I3D file saved to: %s.", splines_i3d_path)
261
+
132
262
  # pylint: disable=R0914, R0915
133
263
  def _add_fields(self) -> None:
134
264
  """Adds fields to the map I3D file."""
@@ -166,7 +296,9 @@ class I3d(Component):
166
296
 
167
297
  for field in fields:
168
298
  try:
169
- fitted_field = self.fit_polygon_into_bounds(field, angle=self.rotation)
299
+ fitted_field = self.fit_object_into_bounds(
300
+ polygon_points=field, angle=self.rotation
301
+ )
170
302
  except ValueError as e:
171
303
  self.logger.warning(
172
304
  "Field %s could not be fitted into the map bounds with error: %s",
maps4fs/generator/map.py CHANGED
@@ -4,14 +4,50 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  import shutil
7
- from typing import Any, Generator, NamedTuple
7
+ from typing import Any, Generator
8
+
9
+ from pydantic import BaseModel
8
10
 
9
11
  from maps4fs.generator.component import Component
10
12
  from maps4fs.generator.game import Game
11
13
  from maps4fs.logger import Logger
12
14
 
13
15
 
14
- class DEMSettings(NamedTuple):
16
+ class SettingsModel(BaseModel):
17
+ """Base class for settings models. It provides methods to convert settings to and from JSON."""
18
+
19
+ @classmethod
20
+ def all_settings_to_json(cls) -> dict[str, dict[str, Any]]:
21
+ """Get all settings of the current class and its subclasses as a dictionary.
22
+
23
+ Returns:
24
+ dict[str, dict[str, Any]]: Dictionary with settings of the current class and its
25
+ subclasses.
26
+ """
27
+ all_settings = {}
28
+ for subclass in cls.__subclasses__():
29
+ all_settings[subclass.__name__] = subclass().model_dump()
30
+
31
+ return all_settings
32
+
33
+ @classmethod
34
+ def all_settings_from_json(cls, data: dict) -> dict[str, SettingsModel]:
35
+ """Create settings instances from JSON data.
36
+
37
+ Arguments:
38
+ data (dict): JSON data.
39
+
40
+ Returns:
41
+ dict[str, Type[SettingsModel]]: Dictionary with settings instances.
42
+ """
43
+ settings = {}
44
+ for subclass in cls.__subclasses__():
45
+ settings[subclass.__name__] = subclass(**data[subclass.__name__])
46
+
47
+ return settings
48
+
49
+
50
+ class DEMSettings(SettingsModel):
15
51
  """Represents the advanced settings for DEM component.
16
52
 
17
53
  Attributes:
@@ -31,19 +67,21 @@ class DEMSettings(NamedTuple):
31
67
  water_depth: int = 0
32
68
 
33
69
 
34
- class BackgroundSettings(NamedTuple):
70
+ class BackgroundSettings(SettingsModel):
35
71
  """Represents the advanced settings for background component.
36
72
 
37
73
  Attributes:
38
74
  generate_background (bool): generate obj files for the background terrain.
39
75
  generate_water (bool): generate obj files for the water.
76
+ resize_factor (float): resize factor for the background and water.
40
77
  """
41
78
 
42
79
  generate_background: bool = True
43
80
  generate_water: bool = True
81
+ resize_factor: float = 1 / 8
44
82
 
45
83
 
46
- class GRLESettings(NamedTuple):
84
+ class GRLESettings(SettingsModel):
47
85
  """Represents the advanced settings for GRLE component.
48
86
 
49
87
  Attributes:
@@ -55,7 +93,7 @@ class GRLESettings(NamedTuple):
55
93
  random_plants: bool = True
56
94
 
57
95
 
58
- class I3DSettings(NamedTuple):
96
+ class I3DSettings(SettingsModel):
59
97
  """Represents the advanced settings for I3D component.
60
98
 
61
99
  Attributes:
@@ -65,7 +103,7 @@ class I3DSettings(NamedTuple):
65
103
  forest_density: int = 10
66
104
 
67
105
 
68
- class TextureSettings(NamedTuple):
106
+ class TextureSettings(SettingsModel):
69
107
  """Represents the advanced settings for texture component.
70
108
 
71
109
  Attributes:
@@ -79,7 +117,18 @@ class TextureSettings(NamedTuple):
79
117
  skip_drains: bool = False
80
118
 
81
119
 
82
- # pylint: disable=R0913, R0902
120
+ class SplineSettings(SettingsModel):
121
+ """Represents the advanced settings for spline component.
122
+
123
+ Attributes:
124
+ spline_density (int): the number of extra points that will be added between each two
125
+ existing points.
126
+ """
127
+
128
+ spline_density: int = 4
129
+
130
+
131
+ # pylint: disable=R0913, R0902, R0914
83
132
  class Map:
84
133
  """Class used to generate map using all components.
85
134
 
@@ -99,11 +148,13 @@ class Map:
99
148
  rotation: int,
100
149
  map_directory: str,
101
150
  logger: Any = None,
151
+ custom_osm: str | None = None,
102
152
  dem_settings: DEMSettings = DEMSettings(),
103
153
  background_settings: BackgroundSettings = BackgroundSettings(),
104
154
  grle_settings: GRLESettings = GRLESettings(),
105
155
  i3d_settings: I3DSettings = I3DSettings(),
106
156
  texture_settings: TextureSettings = TextureSettings(),
157
+ spline_settings: SplineSettings = SplineSettings(),
107
158
  **kwargs,
108
159
  ):
109
160
  if not logger:
@@ -126,6 +177,16 @@ class Map:
126
177
 
127
178
  self.logger.info("Game was set to %s", game.code)
128
179
 
180
+ self.custom_osm = custom_osm
181
+ self.logger.info("Custom OSM file: %s", custom_osm)
182
+
183
+ # Make a copy of a custom osm file to the map directory, so it will be
184
+ # included in the output archive.
185
+ if custom_osm:
186
+ copy_path = os.path.join(self.map_directory, "custom_osm.osm")
187
+ shutil.copyfile(custom_osm, copy_path)
188
+ self.logger.debug("Custom OSM file copied to %s", copy_path)
189
+
129
190
  self.dem_settings = dem_settings
130
191
  self.logger.info("DEM settings: %s", dem_settings)
131
192
  self.background_settings = background_settings
@@ -136,6 +197,8 @@ class Map:
136
197
  self.logger.info("I3D settings: %s", i3d_settings)
137
198
  self.texture_settings = texture_settings
138
199
  self.logger.info("Texture settings: %s", texture_settings)
200
+ self.spline_settings = spline_settings
201
+ self.logger.info("Spline settings: %s", spline_settings)
139
202
 
140
203
  os.makedirs(self.map_directory, exist_ok=True)
141
204
  self.logger.debug("Map directory created: %s", self.map_directory)
@@ -372,7 +372,7 @@ class Texture(Component):
372
372
  ),
373
373
  )
374
374
 
375
- # pylint: disable=no-member
375
+ # pylint: disable=no-member, R0912
376
376
  def draw(self) -> None:
377
377
  """Iterates over layers and fills them with polygons from OSM data."""
378
378
  layers = self.layers_by_priority()
@@ -409,11 +409,23 @@ class Texture(Component):
409
409
 
410
410
  mask = cv2.bitwise_not(cumulative_image)
411
411
 
412
- for polygon in self.polygons(layer.tags, layer.width, layer.info_layer): # type: ignore
412
+ for polygon in self.objects_generator( # type: ignore
413
+ layer.tags, layer.width, layer.info_layer
414
+ ):
413
415
  if layer.info_layer:
414
- info_layer_data[layer.info_layer].append(self.np_to_polygon_points(polygon))
416
+ info_layer_data[layer.info_layer].append(
417
+ self.np_to_polygon_points(polygon) # type: ignore
418
+ )
415
419
  cv2.fillPoly(layer_image, [polygon], color=255) # type: ignore
416
420
 
421
+ if layer.info_layer == "roads":
422
+ for linestring in self.objects_generator(
423
+ layer.tags, layer.width, layer.info_layer, yield_linestrings=True
424
+ ):
425
+ info_layer_data[f"{layer.info_layer}_polylines"].append(
426
+ linestring # type: ignore
427
+ )
428
+
417
429
  output_image = cv2.bitwise_and(layer_image, mask)
418
430
 
419
431
  cumulative_image = cv2.bitwise_or(cumulative_image, output_image)
@@ -422,8 +434,16 @@ class Texture(Component):
422
434
  self.logger.debug("Texture %s saved.", layer_path)
423
435
 
424
436
  # Save info layer data.
437
+ if os.path.isfile(self.info_layer_path):
438
+ self.logger.debug(
439
+ "File %s already exists, will update to avoid overwriting.", self.info_layer_path
440
+ )
441
+ with open(self.info_layer_path, "r", encoding="utf-8") as f:
442
+ info_layer_data.update(json.load(f))
443
+
425
444
  with open(self.info_layer_path, "w", encoding="utf-8") as f:
426
445
  json.dump(info_layer_data, f, ensure_ascii=False, indent=4)
446
+ self.logger.debug("Info layer data saved to %s.", self.info_layer_path)
427
447
 
428
448
  if cumulative_image is not None:
429
449
  self.draw_base_layer(cumulative_image)
@@ -617,31 +637,73 @@ class Texture(Component):
617
637
  converters = {"Polygon": self._skip, "LineString": self._sequence, "Point": self._sequence}
618
638
  return converters.get(geom_type) # type: ignore
619
639
 
620
- def polygons(
640
+ def objects_generator(
621
641
  self,
622
642
  tags: dict[str, str | list[str] | bool],
623
643
  width: int | None,
624
644
  info_layer: str | None = None,
625
- ) -> Generator[np.ndarray, None, None]:
645
+ yield_linestrings: bool = False,
646
+ ) -> Generator[np.ndarray, None, None] | Generator[list[tuple[int, int]], None, None]:
626
647
  """Generator which yields numpy arrays of polygons from OSM data.
627
648
 
628
649
  Arguments:
629
650
  tags (dict[str, str | list[str]]): Dictionary of tags to search for.
630
651
  width (int | None): Width of the polygon in meters (only for LineString).
631
652
  info_layer (str | None): Name of the corresponding info layer.
653
+ yield_linestrings (bool): Flag to determine if the LineStrings should be yielded.
632
654
 
633
655
  Yields:
634
- Generator[np.ndarray, None, None]: Numpy array of polygon points.
656
+ Generator[np.ndarray, None, None] | Generator[list[tuple[int, int]], None, None]:
657
+ Numpy array of polygon points or list of point coordinates.
635
658
  """
636
659
  is_fieds = info_layer == "fields"
637
660
  try:
638
- objects = ox.features_from_bbox(bbox=self.new_bbox, tags=tags)
639
- except Exception: # pylint: disable=W0718
640
- self.logger.debug("Error fetching objects for tags: %s.", tags)
661
+ if self.map.custom_osm is not None:
662
+ objects = ox.features_from_xml(self.map.custom_osm, tags=tags)
663
+ else:
664
+ objects = ox.features_from_bbox(bbox=self.new_bbox, tags=tags)
665
+ except Exception as e: # pylint: disable=W0718
666
+ self.logger.debug("Error fetching objects for tags: %s. Error: %s.", tags, e)
641
667
  return
642
668
  objects_utm = ox.projection.project_gdf(objects, to_latlong=False)
643
669
  self.logger.debug("Fetched %s elements for tags: %s.", len(objects_utm), tags)
644
670
 
671
+ method = self.linestrings_generator if yield_linestrings else self.polygons_generator
672
+
673
+ yield from method(objects_utm, width, is_fieds)
674
+
675
+ def linestrings_generator(
676
+ self, objects_utm: pd.core.frame.DataFrame, *args, **kwargs
677
+ ) -> Generator[list[tuple[int, int]], None, None]:
678
+ """Generator which yields lists of point coordinates which represent LineStrings from OSM.
679
+
680
+ Arguments:
681
+ objects_utm (pd.core.frame.DataFrame): Dataframe with OSM objects in UTM format.
682
+
683
+ Yields:
684
+ Generator[list[tuple[int, int]], None, None]: List of point coordinates.
685
+ """
686
+ for _, obj in objects_utm.iterrows():
687
+ geometry = obj["geometry"]
688
+ if isinstance(geometry, shapely.geometry.linestring.LineString):
689
+ points = [
690
+ (self.get_relative_x(x), self.get_relative_y(y)) for x, y in geometry.coords
691
+ ]
692
+ yield points
693
+
694
+ def polygons_generator(
695
+ self, objects_utm: pd.core.frame.DataFrame, width: int | None, is_fieds: bool
696
+ ) -> Generator[np.ndarray, None, None]:
697
+ """Generator which yields numpy arrays of polygons from OSM data.
698
+
699
+ Arguments:
700
+ objects_utm (pd.core.frame.DataFrame): Dataframe with OSM objects in UTM format.
701
+ width (int | None): Width of the polygon in meters (only for LineString).
702
+ is_fieds (bool): Flag to determine if the fields should be padded.
703
+
704
+ Yields:
705
+ Generator[np.ndarray, None, None]: Numpy array of polygon points.
706
+ """
645
707
  for _, obj in objects_utm.iterrows():
646
708
  polygon = self._to_polygon(obj, width)
647
709
  if polygon is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: maps4fs
3
- Version: 1.3.6
3
+ Version: 1.3.9
4
4
  Summary: Generate map templates for Farming Simulator from real places.
5
5
  Author-email: iwatkot <iwatkot@gmail.com>
6
6
  License: MIT License
@@ -22,6 +22,7 @@ Requires-Dist: trimesh
22
22
  Requires-Dist: imageio
23
23
  Requires-Dist: tifffile
24
24
  Requires-Dist: pympler
25
+ Requires-Dist: pydantic
25
26
 
26
27
  <div align="center" markdown>
27
28
  <a href="https://discord.gg/Sj5QKKyE42">
@@ -71,7 +72,9 @@ Requires-Dist: pympler
71
72
  🌿 Automatically generates decorative foliage 🆕<br>
72
73
  🌲 Automatically generates forests 🆕<br>
73
74
  🌊 Automatically generates water planes 🆕<br>
75
+ 📈 Automatically generates splines 🆕<br>
74
76
  🌍 Based on real-world data from OpenStreetMap<br>
77
+ 🗺️ Supports [custom OSM maps](/docs/custom_osm.md)<br>
75
78
  🏞️ Generates height map using SRTM dataset<br>
76
79
  📦 Provides a ready-to-use map template for the Giants Editor<br>
77
80
  🚜 Supports Farming Simulator 22 and 25<br>
@@ -93,6 +96,8 @@ Requires-Dist: pympler
93
96
  🌲 Automatically generates forests.<br><br>
94
97
  <img src="https://github.com/user-attachments/assets/cce7d4e0-cba2-4dd2-b22d-03137fb2e860"><br>
95
98
  🌊 Automatically generates water planes.<br><br>
99
+ <img src="https://github.com/user-attachments/assets/0b05b511-a595-48e7-a353-8298081314a4"><br>
100
+ 📈 Automatically generates splines.<br><br>
96
101
  <img src="https://github.com/user-attachments/assets/80e5923c-22c7-4dc0-8906-680902511f3a"><br>
97
102
  🗒️ True-to-life blueprints for fast and precise modding.<br><br>
98
103
  <img width="480" src="https://github.com/user-attachments/assets/1a8802d2-6a3b-4bfa-af2b-7c09478e199b"><br>
@@ -501,6 +506,12 @@ You can also apply some advanced settings to the map generation process. Note th
501
506
 
502
507
  - 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.
503
508
 
509
+ - 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.
510
+
511
+ ## Splines Advanced settings
512
+
513
+ - 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.
514
+
504
515
  ## Resources
505
516
  In this section, you'll find a list of the resources that you need to create a map for the Farming Simulator.<br>
506
517
  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>
@@ -528,3 +539,4 @@ But also, I want to thank the people who helped me with the project in some way,
528
539
  - [BFernaesds](https://github.com/BFernaesds) - for the manual tests of the app.
529
540
  - [gamerdesigns](https://github.com/gamerdesigns) - for the manual tests of the app.
530
541
  - [Tox3](https://github.com/Tox3) - for the manual tests of the app.
542
+ - [Lucandia](https://github.com/Lucandia) - for the awesome StreamLit [widget to preview STL files](https://github.com/Lucandia/streamlit_stl).
@@ -0,0 +1,21 @@
1
+ maps4fs/__init__.py,sha256=LMzzORK3Q3OjXmmRJ03CpS2SMP6zTwKNnUUei3P7s40,300
2
+ maps4fs/logger.py,sha256=B-NEYpMjPAAqlV4VpfTi6nbBFnEABVtQOaYe6nMpidg,1489
3
+ maps4fs/generator/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
4
+ maps4fs/generator/background.py,sha256=-fQsrJogSZitxQ4tTDN4xLgaNrOVsXFS1bFfPkpktFE,22832
5
+ maps4fs/generator/component.py,sha256=58UQgdR-7KlWHTfwLesNNK76BTRsiVngRa6B64OKjhc,20065
6
+ maps4fs/generator/config.py,sha256=0QmK052B8bxyHVhg3jzCORLfOBMMmqVfhhbqXKf6OMk,4383
7
+ maps4fs/generator/dem.py,sha256=MZf3ZjawJ977TxqB1q9nNpvPZUNwfmm2EaJDtVU-eCU,15939
8
+ maps4fs/generator/game.py,sha256=jjo7CTwHHSkRpeD_QgRXkhR_NxI09C4kMxz-nYOTM4A,7931
9
+ maps4fs/generator/grle.py,sha256=onhZovvRtireDfw7wEOL0CZmOmoVzRPY-R4TlvbOUMI,17561
10
+ maps4fs/generator/i3d.py,sha256=vbH7G0kDOGC6gbNDE-QYxMnKOMJ-vCGYdaKhWwci4ZU,23748
11
+ maps4fs/generator/map.py,sha256=bs3V5-E9hF77I_v8yanL--6oc5MgTcfrdTQrIAziqq8,10964
12
+ maps4fs/generator/qgis.py,sha256=Es8hLuqN_KH8lDfnJE6He2rWYbAKJ3RGPn-o87S6CPI,6116
13
+ maps4fs/generator/texture.py,sha256=fZN0soGWk8-f3GuQWiwJ2yQTsmL9fLWVlgqeLI6ePi4,30648
14
+ maps4fs/toolbox/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
15
+ maps4fs/toolbox/background.py,sha256=9BXWNqs_n3HgqDiPztWylgYk_QM4YgBpe6_ZNQAWtSc,2154
16
+ maps4fs/toolbox/dem.py,sha256=z9IPFNmYbjiigb3t02ZenI3Mo8odd19c5MZbjDEovTo,3525
17
+ maps4fs-1.3.9.dist-info/LICENSE.md,sha256=pTKD_oUexcn-yccFCTrMeLkZy0ifLRa-VNcDLqLZaIw,10749
18
+ maps4fs-1.3.9.dist-info/METADATA,sha256=XWb_csNJp4zcnR8O85o0A2v4bi9VNFB-tcxYDGGgM-k,32129
19
+ maps4fs-1.3.9.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
20
+ maps4fs-1.3.9.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
21
+ maps4fs-1.3.9.dist-info/RECORD,,
@@ -1,21 +0,0 @@
1
- maps4fs/__init__.py,sha256=MlM_vkLH_22xoBwhoRD52JDECCmeAJ8gBQr7RMQZmis,261
2
- maps4fs/logger.py,sha256=B-NEYpMjPAAqlV4VpfTi6nbBFnEABVtQOaYe6nMpidg,1489
3
- maps4fs/generator/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
4
- maps4fs/generator/background.py,sha256=KFoO6GKaNrJDUoMrDyeroZG63Cv9aGkMgHaa0QYEpiU,22306
5
- maps4fs/generator/component.py,sha256=XN-3Zx0bujugpuRk3YB-pYNwUHREdyt_cLxPd7pr57g,17967
6
- maps4fs/generator/config.py,sha256=0QmK052B8bxyHVhg3jzCORLfOBMMmqVfhhbqXKf6OMk,4383
7
- maps4fs/generator/dem.py,sha256=MZf3ZjawJ977TxqB1q9nNpvPZUNwfmm2EaJDtVU-eCU,15939
8
- maps4fs/generator/game.py,sha256=ZQeYzPzPB3CG41avdhNCyTZpHEeedqNBuAbNevTZuXg,7931
9
- maps4fs/generator/grle.py,sha256=xKIpyhYsZol-IXcBULbX7wWZ1n83BWTZqaf8FLodchE,17499
10
- maps4fs/generator/i3d.py,sha256=bW7FLAISFKCPUmad7ANz1loWI07oEZlEQOEL_tv0YmQ,18483
11
- maps4fs/generator/map.py,sha256=a-nwDsKq6u9RLB2htueXtJtdDZbocpj_KxGhBw5AoEI,8776
12
- maps4fs/generator/qgis.py,sha256=Es8hLuqN_KH8lDfnJE6He2rWYbAKJ3RGPn-o87S6CPI,6116
13
- maps4fs/generator/texture.py,sha256=tNhv-_AOrv4Wf7knbrN9LZBkvApgsrGffGPAzZScr7g,27745
14
- maps4fs/toolbox/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
15
- maps4fs/toolbox/background.py,sha256=9BXWNqs_n3HgqDiPztWylgYk_QM4YgBpe6_ZNQAWtSc,2154
16
- maps4fs/toolbox/dem.py,sha256=z9IPFNmYbjiigb3t02ZenI3Mo8odd19c5MZbjDEovTo,3525
17
- maps4fs-1.3.6.dist-info/LICENSE.md,sha256=pTKD_oUexcn-yccFCTrMeLkZy0ifLRa-VNcDLqLZaIw,10749
18
- maps4fs-1.3.6.dist-info/METADATA,sha256=pczjM5FWcRCAJiNtW3JRwl0SeNYEAlQUwc1V2UWyjtY,31082
19
- maps4fs-1.3.6.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
20
- maps4fs-1.3.6.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
21
- maps4fs-1.3.6.dist-info/RECORD,,