maps4fs 1.3.7__py3-none-any.whl → 1.4.0__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 +2 -0
- maps4fs/generator/background.py +13 -5
- maps4fs/generator/component.py +71 -22
- maps4fs/generator/game.py +1 -1
- maps4fs/generator/grle.py +4 -2
- maps4fs/generator/i3d.py +134 -2
- maps4fs/generator/map.py +65 -6
- maps4fs/generator/texture.py +67 -8
- {maps4fs-1.3.7.dist-info → maps4fs-1.4.0.dist-info}/METADATA +11 -1
- maps4fs-1.4.0.dist-info/RECORD +21 -0
- maps4fs-1.3.7.dist-info/RECORD +0 -21
- {maps4fs-1.3.7.dist-info → maps4fs-1.4.0.dist-info}/LICENSE.md +0 -0
- {maps4fs-1.3.7.dist-info → maps4fs-1.4.0.dist-info}/WHEEL +0 -0
- {maps4fs-1.3.7.dist-info → maps4fs-1.4.0.dist-info}/top_level.txt +0 -0
maps4fs/__init__.py
CHANGED
maps4fs/generator/background.py
CHANGED
@@ -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=
|
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,
|
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 /
|
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)
|
maps4fs/generator/component.py
CHANGED
@@ -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
|
342
|
-
self,
|
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
|
-
|
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
|
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
|
-
|
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
|
370
|
-
|
371
|
-
self.logger.debug("Rotated and translated
|
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
|
-
|
375
|
-
if
|
376
|
-
raise ValueError("The
|
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
|
392
|
+
# Intersect the osm_object with the bounds to fit it within the map
|
382
393
|
try:
|
383
|
-
|
384
|
-
self.logger.debug("Fitted the
|
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
|
398
|
+
f"Could not fit the osm_object into the bounds: {e}"
|
388
399
|
)
|
389
400
|
|
390
|
-
if not isinstance(
|
391
|
-
raise ValueError("The fitted
|
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
|
-
|
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
|
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,
|
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.
|
148
|
-
field,
|
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
|
-
|
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.
|
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
|
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
|
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(
|
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(
|
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(
|
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(
|
106
|
+
class TextureSettings(SettingsModel):
|
69
107
|
"""Represents the advanced settings for texture component.
|
70
108
|
|
71
109
|
Attributes:
|
@@ -79,6 +117,17 @@ class TextureSettings(NamedTuple):
|
|
79
117
|
skip_drains: bool = False
|
80
118
|
|
81
119
|
|
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
|
+
|
82
131
|
# pylint: disable=R0913, R0902, R0914
|
83
132
|
class Map:
|
84
133
|
"""Class used to generate map using all components.
|
@@ -105,6 +154,7 @@ class Map:
|
|
105
154
|
grle_settings: GRLESettings = GRLESettings(),
|
106
155
|
i3d_settings: I3DSettings = I3DSettings(),
|
107
156
|
texture_settings: TextureSettings = TextureSettings(),
|
157
|
+
spline_settings: SplineSettings = SplineSettings(),
|
108
158
|
**kwargs,
|
109
159
|
):
|
110
160
|
if not logger:
|
@@ -130,6 +180,13 @@ class Map:
|
|
130
180
|
self.custom_osm = custom_osm
|
131
181
|
self.logger.info("Custom OSM file: %s", custom_osm)
|
132
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
|
+
|
133
190
|
self.dem_settings = dem_settings
|
134
191
|
self.logger.info("DEM settings: %s", dem_settings)
|
135
192
|
self.background_settings = background_settings
|
@@ -140,6 +197,8 @@ class Map:
|
|
140
197
|
self.logger.info("I3D settings: %s", i3d_settings)
|
141
198
|
self.texture_settings = texture_settings
|
142
199
|
self.logger.info("Texture settings: %s", texture_settings)
|
200
|
+
self.spline_settings = spline_settings
|
201
|
+
self.logger.info("Spline settings: %s", spline_settings)
|
143
202
|
|
144
203
|
os.makedirs(self.map_directory, exist_ok=True)
|
145
204
|
self.logger.debug("Map directory created: %s", self.map_directory)
|
maps4fs/generator/texture.py
CHANGED
@@ -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.
|
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(
|
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,21 +637,24 @@ 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
|
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
|
-
|
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]
|
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:
|
@@ -639,12 +662,48 @@ class Texture(Component):
|
|
639
662
|
objects = ox.features_from_xml(self.map.custom_osm, tags=tags)
|
640
663
|
else:
|
641
664
|
objects = ox.features_from_bbox(bbox=self.new_bbox, tags=tags)
|
642
|
-
except Exception: # pylint: disable=W0718
|
643
|
-
self.logger.debug("Error fetching objects for tags: %s.", tags)
|
665
|
+
except Exception as e: # pylint: disable=W0718
|
666
|
+
self.logger.debug("Error fetching objects for tags: %s. Error: %s.", tags, e)
|
644
667
|
return
|
645
668
|
objects_utm = ox.projection.project_gdf(objects, to_latlong=False)
|
646
669
|
self.logger.debug("Fetched %s elements for tags: %s.", len(objects_utm), tags)
|
647
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
|
+
"""
|
648
707
|
for _, obj in objects_utm.iterrows():
|
649
708
|
polygon = self._to_polygon(obj, width)
|
650
709
|
if polygon is None:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: maps4fs
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.4.0
|
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,6 +72,7 @@ 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>
|
75
77
|
🗺️ Supports [custom OSM maps](/docs/custom_osm.md)<br>
|
76
78
|
🏞️ Generates height map using SRTM dataset<br>
|
@@ -94,6 +96,8 @@ Requires-Dist: pympler
|
|
94
96
|
🌲 Automatically generates forests.<br><br>
|
95
97
|
<img src="https://github.com/user-attachments/assets/cce7d4e0-cba2-4dd2-b22d-03137fb2e860"><br>
|
96
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>
|
97
101
|
<img src="https://github.com/user-attachments/assets/80e5923c-22c7-4dc0-8906-680902511f3a"><br>
|
98
102
|
🗒️ True-to-life blueprints for fast and precise modding.<br><br>
|
99
103
|
<img width="480" src="https://github.com/user-attachments/assets/1a8802d2-6a3b-4bfa-af2b-7c09478e199b"><br>
|
@@ -502,6 +506,12 @@ You can also apply some advanced settings to the map generation process. Note th
|
|
502
506
|
|
503
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.
|
504
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
|
+
|
505
515
|
## Resources
|
506
516
|
In this section, you'll find a list of the resources that you need to create a map for the Farming Simulator.<br>
|
507
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>
|
@@ -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.4.0.dist-info/LICENSE.md,sha256=pTKD_oUexcn-yccFCTrMeLkZy0ifLRa-VNcDLqLZaIw,10749
|
18
|
+
maps4fs-1.4.0.dist-info/METADATA,sha256=2JDjj8W5wFy15bzL0FrN98l7axPgvYHtsmvWPEYPObY,32129
|
19
|
+
maps4fs-1.4.0.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
20
|
+
maps4fs-1.4.0.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
|
21
|
+
maps4fs-1.4.0.dist-info/RECORD,,
|
maps4fs-1.3.7.dist-info/RECORD
DELETED
@@ -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=VEcsh-nvJ8kF1NevBwNS0tW5oiLP8gIF61ttz6XrOHc,8920
|
12
|
-
maps4fs/generator/qgis.py,sha256=Es8hLuqN_KH8lDfnJE6He2rWYbAKJ3RGPn-o87S6CPI,6116
|
13
|
-
maps4fs/generator/texture.py,sha256=PCQA2OVjTKrrMbheZ36pRAkGT8everovgvfMRwVWTFs,27894
|
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.7.dist-info/LICENSE.md,sha256=pTKD_oUexcn-yccFCTrMeLkZy0ifLRa-VNcDLqLZaIw,10749
|
18
|
-
maps4fs-1.3.7.dist-info/METADATA,sha256=3yZGkkoXbOYdGYj9nANHuxY0uhT_8TNVSywkz_WhoS0,31286
|
19
|
-
maps4fs-1.3.7.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
20
|
-
maps4fs-1.3.7.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
|
21
|
-
maps4fs-1.3.7.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|