maps4fs 1.1.6__py3-none-any.whl → 1.2.4__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/generator/background.py +178 -15
- maps4fs/generator/component.py +12 -2
- maps4fs/generator/game.py +15 -0
- maps4fs/generator/grle.py +211 -0
- maps4fs/generator/i3d.py +163 -1
- maps4fs/generator/map.py +16 -1
- maps4fs/generator/texture.py +61 -17
- {maps4fs-1.1.6.dist-info → maps4fs-1.2.4.dist-info}/METADATA +23 -3
- maps4fs-1.2.4.dist-info/RECORD +21 -0
- maps4fs-1.1.6.dist-info/RECORD +0 -21
- {maps4fs-1.1.6.dist-info → maps4fs-1.2.4.dist-info}/LICENSE.md +0 -0
- {maps4fs-1.1.6.dist-info → maps4fs-1.2.4.dist-info}/WHEEL +0 -0
- {maps4fs-1.1.6.dist-info → maps4fs-1.2.4.dist-info}/top_level.txt +0 -0
maps4fs/generator/background.py
CHANGED
@@ -3,8 +3,10 @@ around the map."""
|
|
3
3
|
|
4
4
|
from __future__ import annotations
|
5
5
|
|
6
|
+
import json
|
6
7
|
import os
|
7
8
|
import shutil
|
9
|
+
from copy import deepcopy
|
8
10
|
|
9
11
|
import cv2
|
10
12
|
import numpy as np
|
@@ -17,6 +19,7 @@ from maps4fs.generator.dem import (
|
|
17
19
|
DEFAULT_PLATEAU,
|
18
20
|
DEM,
|
19
21
|
)
|
22
|
+
from maps4fs.generator.texture import Texture
|
20
23
|
|
21
24
|
DEFAULT_DISTANCE = 2048
|
22
25
|
RESIZE_FACTOR = 1 / 8
|
@@ -25,6 +28,7 @@ FULL_PREVIEW_NAME = "PREVIEW"
|
|
25
28
|
ELEMENTS = [FULL_NAME, FULL_PREVIEW_NAME]
|
26
29
|
|
27
30
|
|
31
|
+
# pylint: disable=R0902
|
28
32
|
class Background(Component):
|
29
33
|
"""Component for creating 3D obj files based on DEM data around the map.
|
30
34
|
|
@@ -43,7 +47,9 @@ class Background(Component):
|
|
43
47
|
def preprocess(self) -> None:
|
44
48
|
"""Registers the DEMs for the background terrain."""
|
45
49
|
self.light_version = self.kwargs.get("light_version", False)
|
50
|
+
self.water_depth = self.kwargs.get("water_depth", 0)
|
46
51
|
self.stl_preview_path: str | None = None
|
52
|
+
self.water_resources_path: str | None = None
|
47
53
|
|
48
54
|
if self.rotation:
|
49
55
|
self.logger.debug("Rotation is enabled: %s.", self.rotation)
|
@@ -51,21 +57,29 @@ class Background(Component):
|
|
51
57
|
else:
|
52
58
|
output_size_multiplier = 1
|
53
59
|
|
54
|
-
background_size = self.map_size + DEFAULT_DISTANCE * 2
|
55
|
-
rotated_size = int(background_size * output_size_multiplier)
|
60
|
+
self.background_size = self.map_size + DEFAULT_DISTANCE * 2
|
61
|
+
self.rotated_size = int(self.background_size * output_size_multiplier)
|
56
62
|
|
57
63
|
self.background_directory = os.path.join(self.map_directory, "background")
|
64
|
+
self.water_directory = os.path.join(self.map_directory, "water")
|
58
65
|
os.makedirs(self.background_directory, exist_ok=True)
|
66
|
+
os.makedirs(self.water_directory, exist_ok=True)
|
59
67
|
|
60
68
|
autoprocesses = [self.kwargs.get("auto_process", False), False]
|
69
|
+
self.output_paths = [
|
70
|
+
os.path.join(self.background_directory, f"{name}.png") for name in ELEMENTS
|
71
|
+
]
|
72
|
+
self.not_substracted_path = os.path.join(self.background_directory, "not_substracted.png")
|
73
|
+
|
61
74
|
dems = []
|
62
75
|
|
63
|
-
for name, autoprocess in zip(ELEMENTS, autoprocesses):
|
76
|
+
for name, autoprocess, output_path in zip(ELEMENTS, autoprocesses, self.output_paths):
|
64
77
|
dem = DEM(
|
65
78
|
self.game,
|
79
|
+
self.map,
|
66
80
|
self.coordinates,
|
67
|
-
background_size,
|
68
|
-
rotated_size,
|
81
|
+
self.background_size,
|
82
|
+
self.rotated_size,
|
69
83
|
self.rotation,
|
70
84
|
self.map_directory,
|
71
85
|
self.logger,
|
@@ -76,8 +90,8 @@ class Background(Component):
|
|
76
90
|
)
|
77
91
|
dem.preprocess()
|
78
92
|
dem.is_preview = self.is_preview(name) # type: ignore
|
79
|
-
dem.set_output_resolution((rotated_size, rotated_size))
|
80
|
-
dem.set_dem_path(
|
93
|
+
dem.set_output_resolution((self.rotated_size, self.rotated_size))
|
94
|
+
dem.set_dem_path(output_path)
|
81
95
|
dems.append(dem)
|
82
96
|
|
83
97
|
self.dems = dems
|
@@ -97,8 +111,17 @@ class Background(Component):
|
|
97
111
|
"""Launches the component processing. Iterates over all tiles and processes them
|
98
112
|
as a result the DEM files will be saved, then based on them the obj files will be
|
99
113
|
generated."""
|
114
|
+
self.create_background_textures()
|
115
|
+
|
100
116
|
for dem in self.dems:
|
101
117
|
dem.process()
|
118
|
+
if not dem.is_preview: # type: ignore
|
119
|
+
shutil.copyfile(dem.dem_path, self.not_substracted_path)
|
120
|
+
|
121
|
+
if self.water_depth:
|
122
|
+
self.subtraction()
|
123
|
+
|
124
|
+
for dem in self.dems:
|
102
125
|
if not dem.is_preview: # type: ignore
|
103
126
|
cutted_dem_path = self.cutout(dem.dem_path)
|
104
127
|
if self.game.additional_dem_name is not None:
|
@@ -106,6 +129,7 @@ class Background(Component):
|
|
106
129
|
|
107
130
|
if not self.light_version:
|
108
131
|
self.generate_obj_files()
|
132
|
+
self.generate_water_resources_obj()
|
109
133
|
else:
|
110
134
|
self.logger.info("Light version is enabled, obj files will not be generated.")
|
111
135
|
|
@@ -222,13 +246,20 @@ class Background(Component):
|
|
222
246
|
return main_dem_path
|
223
247
|
|
224
248
|
# pylint: disable=too-many-locals
|
225
|
-
def plane_from_np(
|
249
|
+
def plane_from_np(
|
250
|
+
self,
|
251
|
+
dem_data: np.ndarray,
|
252
|
+
save_path: str,
|
253
|
+
is_preview: bool = False,
|
254
|
+
include_zeros: bool = True,
|
255
|
+
) -> None:
|
226
256
|
"""Generates a 3D obj file based on DEM data.
|
227
257
|
|
228
258
|
Arguments:
|
229
259
|
dem_data (np.ndarray) -- The DEM data as a numpy array.
|
230
260
|
save_path (str) -- The path where the obj file will be saved.
|
231
261
|
is_preview (bool, optional) -- If True, the preview mesh will be generated.
|
262
|
+
include_zeros (bool, optional) -- If True, the mesh will include the zero height values.
|
232
263
|
"""
|
233
264
|
dem_data = cv2.resize( # pylint: disable=no-member
|
234
265
|
dem_data, (0, 0), fx=RESIZE_FACTOR, fy=RESIZE_FACTOR
|
@@ -246,6 +277,9 @@ class Background(Component):
|
|
246
277
|
x, y = np.meshgrid(x, y)
|
247
278
|
z = dem_data
|
248
279
|
|
280
|
+
ground = z.max()
|
281
|
+
self.logger.debug("Ground level: %s", ground)
|
282
|
+
|
249
283
|
self.logger.debug(
|
250
284
|
"Starting to generate a mesh for with shape: %s x %s. This may take a while...",
|
251
285
|
cols,
|
@@ -255,6 +289,8 @@ class Background(Component):
|
|
255
289
|
vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
|
256
290
|
faces = []
|
257
291
|
|
292
|
+
skipped = 0
|
293
|
+
|
258
294
|
for i in range(rows - 1):
|
259
295
|
for j in range(cols - 1):
|
260
296
|
top_left = i * cols + j
|
@@ -262,9 +298,15 @@ class Background(Component):
|
|
262
298
|
bottom_left = top_left + cols
|
263
299
|
bottom_right = bottom_left + 1
|
264
300
|
|
301
|
+
if ground in [z[i, j], z[i, j + 1], z[i + 1, j], z[i + 1, j + 1]]:
|
302
|
+
skipped += 1
|
303
|
+
continue
|
304
|
+
|
265
305
|
faces.append([top_left, bottom_left, bottom_right])
|
266
306
|
faces.append([top_left, bottom_right, top_right])
|
267
307
|
|
308
|
+
self.logger.debug("Skipped faces: %s", skipped)
|
309
|
+
|
268
310
|
faces = np.array(faces) # type: ignore
|
269
311
|
mesh = trimesh.Trimesh(vertices=vertices, faces=faces)
|
270
312
|
|
@@ -282,13 +324,14 @@ class Background(Component):
|
|
282
324
|
mesh.apply_scale([0.5, 0.5, 0.5])
|
283
325
|
self.mesh_to_stl(mesh)
|
284
326
|
else:
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
327
|
+
if not include_zeros:
|
328
|
+
multiplier = self.kwargs.get("multiplier", DEFAULT_MULTIPLIER)
|
329
|
+
if multiplier != 1:
|
330
|
+
z_scaling_factor = 1 / multiplier
|
331
|
+
else:
|
332
|
+
z_scaling_factor = 1 / 2**5
|
333
|
+
self.logger.debug("Z scaling factor: %s", z_scaling_factor)
|
334
|
+
mesh.apply_scale([1 / RESIZE_FACTOR, 1 / RESIZE_FACTOR, z_scaling_factor])
|
292
335
|
|
293
336
|
mesh.export(save_path)
|
294
337
|
self.logger.debug("Obj file saved: %s", save_path)
|
@@ -412,3 +455,123 @@ class Background(Component):
|
|
412
455
|
|
413
456
|
cv2.imwrite(colored_dem_path, dem_data_colored)
|
414
457
|
return colored_dem_path
|
458
|
+
|
459
|
+
def create_background_textures(self) -> None:
|
460
|
+
"""Creates background textures for the map."""
|
461
|
+
if not os.path.isfile(self.game.texture_schema):
|
462
|
+
self.logger.warning("Texture schema file not found: %s", self.game.texture_schema)
|
463
|
+
return
|
464
|
+
|
465
|
+
with open(self.game.texture_schema, "r", encoding="utf-8") as f:
|
466
|
+
layers_schema = json.load(f)
|
467
|
+
|
468
|
+
background_layers = []
|
469
|
+
for layer in layers_schema:
|
470
|
+
if layer.get("background") is True:
|
471
|
+
layer_copy = deepcopy(layer)
|
472
|
+
layer_copy["count"] = 1
|
473
|
+
layer_copy["name"] = f"{layer['name']}_background"
|
474
|
+
background_layers.append(layer_copy)
|
475
|
+
|
476
|
+
if not background_layers:
|
477
|
+
return
|
478
|
+
|
479
|
+
self.background_texture = Texture( # pylint: disable=W0201
|
480
|
+
self.game,
|
481
|
+
self.map,
|
482
|
+
self.coordinates,
|
483
|
+
self.background_size,
|
484
|
+
self.rotated_size,
|
485
|
+
rotation=self.rotation,
|
486
|
+
map_directory=self.map_directory,
|
487
|
+
logger=self.logger,
|
488
|
+
light_version=self.light_version,
|
489
|
+
custom_schema=background_layers,
|
490
|
+
)
|
491
|
+
|
492
|
+
self.background_texture.preprocess()
|
493
|
+
self.background_texture.process()
|
494
|
+
|
495
|
+
processed_layers = self.background_texture.get_background_layers()
|
496
|
+
weights_directory = self.game.weights_dir_path(self.map_directory)
|
497
|
+
background_paths = [layer.path(weights_directory) for layer in processed_layers]
|
498
|
+
self.logger.debug("Found %s background textures.", len(background_paths))
|
499
|
+
|
500
|
+
if not background_paths:
|
501
|
+
self.logger.warning("No background textures found.")
|
502
|
+
return
|
503
|
+
|
504
|
+
# Merge all images into one.
|
505
|
+
background_image = np.zeros((self.background_size, self.background_size), dtype=np.uint8)
|
506
|
+
for path in background_paths:
|
507
|
+
layer = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
|
508
|
+
background_image = cv2.add(background_image, layer) # type: ignore
|
509
|
+
|
510
|
+
background_save_path = os.path.join(self.water_directory, "water_resources.png")
|
511
|
+
cv2.imwrite(background_save_path, background_image)
|
512
|
+
self.logger.info("Background texture saved: %s", background_save_path)
|
513
|
+
self.water_resources_path = background_save_path # pylint: disable=W0201
|
514
|
+
|
515
|
+
def subtraction(self) -> None:
|
516
|
+
"""Subtracts the water depth from the DEM data where the water resources are located."""
|
517
|
+
if not self.water_resources_path:
|
518
|
+
self.logger.warning("Water resources texture not found.")
|
519
|
+
return
|
520
|
+
|
521
|
+
# Single channeled 8 bit image, where the water have values of 255, and the rest 0.
|
522
|
+
water_resources_image = cv2.imread(self.water_resources_path, cv2.IMREAD_UNCHANGED)
|
523
|
+
mask = water_resources_image == 255
|
524
|
+
|
525
|
+
# Make mask a little bit smaller (1 pixel).
|
526
|
+
mask = cv2.erode(mask.astype(np.uint8), np.ones((3, 3), np.uint8), iterations=1).astype(
|
527
|
+
bool
|
528
|
+
)
|
529
|
+
|
530
|
+
for output_path in self.output_paths:
|
531
|
+
if FULL_PREVIEW_NAME in output_path:
|
532
|
+
continue
|
533
|
+
dem_image = cv2.imread(output_path, cv2.IMREAD_UNCHANGED)
|
534
|
+
|
535
|
+
# Create a mask where water_resources_image is 255 (or not 0)
|
536
|
+
# Subtract water_depth from dem_image where mask is True
|
537
|
+
dem_image[mask] = dem_image[mask] - self.water_depth
|
538
|
+
|
539
|
+
# Save the modified dem_image back to the output path
|
540
|
+
cv2.imwrite(output_path, dem_image)
|
541
|
+
self.logger.debug("Water depth subtracted from DEM data: %s", output_path)
|
542
|
+
|
543
|
+
def generate_water_resources_obj(self) -> None:
|
544
|
+
"""Generates 3D obj files based on water resources data."""
|
545
|
+
if not self.water_resources_path:
|
546
|
+
self.logger.warning("Water resources texture not found.")
|
547
|
+
return
|
548
|
+
|
549
|
+
# Single channeled 8 bit image, where the water have values of 255, and the rest 0.
|
550
|
+
plane_water = cv2.imread(self.water_resources_path, cv2.IMREAD_UNCHANGED)
|
551
|
+
dilated_plane_water = cv2.dilate(
|
552
|
+
plane_water.astype(np.uint8), np.ones((5, 5), np.uint8), iterations=5
|
553
|
+
).astype(np.uint8)
|
554
|
+
plane_save_path = os.path.join(self.water_directory, "plane_water.obj")
|
555
|
+
self.plane_from_np(
|
556
|
+
dilated_plane_water, plane_save_path, is_preview=False, include_zeros=False
|
557
|
+
)
|
558
|
+
|
559
|
+
# Single channeled 16 bit DEM image of terrain.
|
560
|
+
background_dem = cv2.imread(self.not_substracted_path, cv2.IMREAD_UNCHANGED)
|
561
|
+
|
562
|
+
# Remove all the values from the background dem where the plane_water is 0.
|
563
|
+
background_dem[plane_water == 0] = 0
|
564
|
+
|
565
|
+
# Dilate the background dem to make the water more smooth.
|
566
|
+
elevated_water = cv2.dilate(background_dem, np.ones((3, 3), np.uint16), iterations=10)
|
567
|
+
|
568
|
+
# Use the background dem as a mask to prevent the original values from being overwritten.
|
569
|
+
mask = background_dem > 0
|
570
|
+
|
571
|
+
# Combine the dilated background dem with non-dilated background dem.
|
572
|
+
elevated_water = np.where(mask, background_dem, elevated_water)
|
573
|
+
elevated_save_path = os.path.join(self.water_directory, "elevated_water.obj")
|
574
|
+
|
575
|
+
self.plane_from_np(
|
576
|
+
elevated_water, elevated_save_path, is_preview=False, include_zeros=False
|
577
|
+
)
|
maps4fs/generator/component.py
CHANGED
@@ -17,6 +17,7 @@ from maps4fs.generator.qgis import save_scripts
|
|
17
17
|
|
18
18
|
if TYPE_CHECKING:
|
19
19
|
from maps4fs.generator.game import Game
|
20
|
+
from maps4fs.generator.map import Map
|
20
21
|
|
21
22
|
|
22
23
|
# pylint: disable=R0801, R0903, R0902, R0904
|
@@ -25,6 +26,7 @@ class Component:
|
|
25
26
|
|
26
27
|
Arguments:
|
27
28
|
game (Game): The game instance for which the map is generated.
|
29
|
+
map (Map): The map instance for which the component is generated.
|
28
30
|
coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
|
29
31
|
map_size (int): The size of the map in pixels.
|
30
32
|
map_rotated_size (int): The size of the map in pixels after rotation.
|
@@ -37,6 +39,7 @@ class Component:
|
|
37
39
|
def __init__(
|
38
40
|
self,
|
39
41
|
game: Game,
|
42
|
+
map: Map, # pylint: disable=W0622
|
40
43
|
coordinates: tuple[float, float],
|
41
44
|
map_size: int,
|
42
45
|
map_rotated_size: int,
|
@@ -46,6 +49,7 @@ class Component:
|
|
46
49
|
**kwargs, # pylint: disable=W0613, R0913, R0917
|
47
50
|
):
|
48
51
|
self.game = game
|
52
|
+
self.map = map
|
49
53
|
self.coordinates = coordinates
|
50
54
|
self.map_size = map_size
|
51
55
|
self.map_rotated_size = map_rotated_size
|
@@ -326,6 +330,7 @@ class Component:
|
|
326
330
|
|
327
331
|
return cs_x, cs_y
|
328
332
|
|
333
|
+
# pylint: disable=R0914
|
329
334
|
def fit_polygon_into_bounds(
|
330
335
|
self, polygon_points: list[tuple[int, int]], margin: int = 0, angle: int = 0
|
331
336
|
) -> list[tuple[int, int]]:
|
@@ -367,8 +372,13 @@ class Component:
|
|
367
372
|
bounds = box(min_x, min_y, max_x, max_y)
|
368
373
|
|
369
374
|
# Intersect the polygon with the bounds to fit it within the map
|
370
|
-
|
371
|
-
|
375
|
+
try:
|
376
|
+
fitted_polygon = polygon.intersection(bounds)
|
377
|
+
self.logger.debug("Fitted the polygon into the bounds: %s", bounds)
|
378
|
+
except Exception as e:
|
379
|
+
raise ValueError( # pylint: disable=W0707
|
380
|
+
f"Could not fit the polygon into the bounds: {e}"
|
381
|
+
)
|
372
382
|
|
373
383
|
if not isinstance(fitted_polygon, Polygon):
|
374
384
|
raise ValueError("The fitted polygon is not a valid polygon.")
|
maps4fs/generator/game.py
CHANGED
@@ -36,6 +36,7 @@ class Game:
|
|
36
36
|
_map_template_path: str | None = None
|
37
37
|
_texture_schema: str | None = None
|
38
38
|
_grle_schema: str | None = None
|
39
|
+
_tree_schema: str | None = None
|
39
40
|
|
40
41
|
# Order matters! Some components depend on others.
|
41
42
|
components = [Texture, I3d, GRLE, Background, Config]
|
@@ -109,6 +110,19 @@ class Game:
|
|
109
110
|
raise ValueError("GRLE layers schema path not set.")
|
110
111
|
return self._grle_schema
|
111
112
|
|
113
|
+
@property
|
114
|
+
def tree_schema(self) -> str:
|
115
|
+
"""Returns the path to the tree layers schema file.
|
116
|
+
|
117
|
+
Raises:
|
118
|
+
ValueError: If the tree layers schema path is not set.
|
119
|
+
|
120
|
+
Returns:
|
121
|
+
str: The path to the tree layers schema file."""
|
122
|
+
if not self._tree_schema:
|
123
|
+
raise ValueError("Tree layers schema path not set.")
|
124
|
+
return self._tree_schema
|
125
|
+
|
112
126
|
def dem_file_path(self, map_directory: str) -> str:
|
113
127
|
"""Returns the path to the DEM file.
|
114
128
|
|
@@ -187,6 +201,7 @@ class FS25(Game):
|
|
187
201
|
_map_template_path = os.path.join(working_directory, "data", "fs25-map-template.zip")
|
188
202
|
_texture_schema = os.path.join(working_directory, "data", "fs25-texture-schema.json")
|
189
203
|
_grle_schema = os.path.join(working_directory, "data", "fs25-grle-schema.json")
|
204
|
+
_tree_schema = os.path.join(working_directory, "data", "fs25-tree-schema.json")
|
190
205
|
|
191
206
|
def dem_file_path(self, map_directory: str) -> str:
|
192
207
|
"""Returns the path to the DEM file.
|
maps4fs/generator/grle.py
CHANGED
@@ -2,12 +2,21 @@
|
|
2
2
|
|
3
3
|
import json
|
4
4
|
import os
|
5
|
+
from random import choice, randint
|
5
6
|
from xml.etree import ElementTree as ET
|
6
7
|
|
7
8
|
import cv2
|
8
9
|
import numpy as np
|
10
|
+
from shapely.geometry import Polygon # type: ignore
|
9
11
|
|
10
12
|
from maps4fs.generator.component import Component
|
13
|
+
from maps4fs.generator.texture import Texture
|
14
|
+
|
15
|
+
ISLAND_SIZE_MIN = 10
|
16
|
+
ISLAND_SIZE_MAX = 200
|
17
|
+
ISLAND_DISTORTION = 0.3
|
18
|
+
ISLAND_VERTEX_COUNT = 30
|
19
|
+
ISLAND_ROUNDING_RADIUS = 15
|
11
20
|
|
12
21
|
|
13
22
|
# pylint: disable=W0223
|
@@ -32,6 +41,7 @@ class GRLE(Component):
|
|
32
41
|
attribute. If the game does not support I3D files, the attribute is set to None."""
|
33
42
|
|
34
43
|
self.farmland_margin = self.kwargs.get("farmland_margin", 0)
|
44
|
+
self.randomize_plants = self.kwargs.get("randomize_plants", True)
|
35
45
|
|
36
46
|
try:
|
37
47
|
grle_schema_path = self.game.grle_schema
|
@@ -76,6 +86,11 @@ class GRLE(Component):
|
|
76
86
|
self.logger.warning("Invalid InfoLayer schema: %s.", info_layer)
|
77
87
|
|
78
88
|
self._add_farmlands()
|
89
|
+
if self.game.code == "FS25":
|
90
|
+
self.logger.info("Game is %s, plants will be added.", self.game.code)
|
91
|
+
self._add_plants()
|
92
|
+
else:
|
93
|
+
self.logger.warning("Adding plants it's not supported for the %s.", self.game.code)
|
79
94
|
|
80
95
|
def previews(self) -> list[str]:
|
81
96
|
"""Returns a list of paths to the preview images (empty list).
|
@@ -184,3 +199,199 @@ class GRLE(Component):
|
|
184
199
|
self.logger.info(
|
185
200
|
"Farmlands added to the InfoLayer PNG file: %s.", info_layer_farmlands_path
|
186
201
|
)
|
202
|
+
|
203
|
+
# pylint: disable=R0915
|
204
|
+
def _add_plants(self) -> None:
|
205
|
+
"""Adds plants to the InfoLayer PNG file."""
|
206
|
+
# 1. Get the path to the densityMap_fruits.png.
|
207
|
+
# 2. Get the path to the base layer (grass).
|
208
|
+
# 3. Detect non-zero areas in the base layer (it's where the plants will be placed).
|
209
|
+
texture_component: Texture | None = self.map.get_component("Texture") # type: ignore
|
210
|
+
if not texture_component:
|
211
|
+
self.logger.warning("Texture component not found in the map.")
|
212
|
+
return
|
213
|
+
|
214
|
+
grass_layer = texture_component.get_layer_by_usage("grass")
|
215
|
+
if not grass_layer:
|
216
|
+
self.logger.warning("Grass layer not found in the texture component.")
|
217
|
+
return
|
218
|
+
|
219
|
+
weights_directory = self.game.weights_dir_path(self.map_directory)
|
220
|
+
grass_image_path = grass_layer.get_preview_or_path(weights_directory)
|
221
|
+
self.logger.debug("Grass image path: %s.", grass_image_path)
|
222
|
+
|
223
|
+
forest_layer = texture_component.get_layer_by_usage("forest")
|
224
|
+
forest_image = None
|
225
|
+
if forest_layer:
|
226
|
+
forest_image_path = forest_layer.get_preview_or_path(weights_directory)
|
227
|
+
self.logger.debug("Forest image path: %s.", forest_image_path)
|
228
|
+
if forest_image_path:
|
229
|
+
# pylint: disable=no-member
|
230
|
+
forest_image = cv2.imread(forest_image_path, cv2.IMREAD_UNCHANGED)
|
231
|
+
|
232
|
+
if not grass_image_path or not os.path.isfile(grass_image_path):
|
233
|
+
self.logger.warning("Base image not found in %s.", grass_image_path)
|
234
|
+
return
|
235
|
+
|
236
|
+
density_map_fruit_path = os.path.join(
|
237
|
+
self.game.weights_dir_path(self.map_directory), "densityMap_fruits.png"
|
238
|
+
)
|
239
|
+
|
240
|
+
self.logger.debug("Density map for fruits path: %s.", density_map_fruit_path)
|
241
|
+
|
242
|
+
if not os.path.isfile(density_map_fruit_path):
|
243
|
+
self.logger.warning("Density map for fruits not found in %s.", density_map_fruit_path)
|
244
|
+
return
|
245
|
+
|
246
|
+
# Single channeled 8-bit image, where non-zero values (255) are where the grass is.
|
247
|
+
grass_image = cv2.imread( # pylint: disable=no-member
|
248
|
+
grass_image_path, cv2.IMREAD_UNCHANGED # pylint: disable=no-member
|
249
|
+
)
|
250
|
+
|
251
|
+
# Density map of the fruits is 2X size of the base image, so we need to resize it.
|
252
|
+
# We'll resize the base image to make it bigger, so we can compare the values.
|
253
|
+
grass_image = cv2.resize( # pylint: disable=no-member
|
254
|
+
grass_image,
|
255
|
+
(grass_image.shape[1] * 2, grass_image.shape[0] * 2),
|
256
|
+
interpolation=cv2.INTER_NEAREST, # pylint: disable=no-member
|
257
|
+
)
|
258
|
+
if forest_image is not None:
|
259
|
+
forest_image = cv2.resize( # pylint: disable=no-member
|
260
|
+
forest_image,
|
261
|
+
(forest_image.shape[1] * 2, forest_image.shape[0] * 2),
|
262
|
+
interpolation=cv2.INTER_NEAREST, # pylint: disable=no-member
|
263
|
+
)
|
264
|
+
|
265
|
+
# Add non zero values from the forest image to the grass image.
|
266
|
+
grass_image[forest_image != 0] = 255
|
267
|
+
|
268
|
+
# B and G channels remain the same (zeros), while we change the R channel.
|
269
|
+
possible_R_values = [33, 65, 97, 129, 161, 193, 225] # pylint: disable=C0103
|
270
|
+
|
271
|
+
# 1st approach: Change the non zero values in the base image to 33 (for debug).
|
272
|
+
# And use the base image as R channel in the density map.
|
273
|
+
|
274
|
+
# pylint: disable=no-member
|
275
|
+
def create_island_of_plants(image: np.ndarray, count: int) -> np.ndarray:
|
276
|
+
"""Create an island of plants in the image.
|
277
|
+
|
278
|
+
Arguments:
|
279
|
+
image (np.ndarray): The image where the island of plants will be created.
|
280
|
+
count (int): The number of islands of plants to create.
|
281
|
+
|
282
|
+
Returns:
|
283
|
+
np.ndarray: The image with the islands of plants.
|
284
|
+
"""
|
285
|
+
for _ in range(count):
|
286
|
+
# Randomly choose the value for the island.
|
287
|
+
plant_value = choice(possible_R_values)
|
288
|
+
# Randomly choose the size of the island.
|
289
|
+
island_size = randint(ISLAND_SIZE_MIN, ISLAND_SIZE_MAX)
|
290
|
+
# Randomly choose the position of the island.
|
291
|
+
# x = np.random.randint(0, image.shape[1] - island_size)
|
292
|
+
# y = np.random.randint(0, image.shape[0] - island_size)
|
293
|
+
x = randint(0, image.shape[1] - island_size)
|
294
|
+
y = randint(0, image.shape[0] - island_size)
|
295
|
+
|
296
|
+
# Randomly choose the shape of the island.
|
297
|
+
# shapes = ["circle", "ellipse", "polygon"]
|
298
|
+
# shape = choice(shapes)
|
299
|
+
|
300
|
+
try:
|
301
|
+
polygon_points = get_rounded_polygon(
|
302
|
+
num_vertices=ISLAND_VERTEX_COUNT,
|
303
|
+
center=(x + island_size // 2, y + island_size // 2),
|
304
|
+
radius=island_size // 2,
|
305
|
+
rounding_radius=ISLAND_ROUNDING_RADIUS,
|
306
|
+
)
|
307
|
+
if not polygon_points:
|
308
|
+
continue
|
309
|
+
|
310
|
+
nodes = np.array(polygon_points, np.int32) # type: ignore
|
311
|
+
cv2.fillPoly(image, [nodes], plant_value) # type: ignore
|
312
|
+
except Exception: # pylint: disable=W0703
|
313
|
+
continue
|
314
|
+
|
315
|
+
return image
|
316
|
+
|
317
|
+
def get_rounded_polygon(
|
318
|
+
num_vertices: int, center: tuple[int, int], radius: int, rounding_radius: int
|
319
|
+
) -> list[tuple[int, int]] | None:
|
320
|
+
"""Get a randomly rounded polygon.
|
321
|
+
|
322
|
+
Arguments:
|
323
|
+
num_vertices (int): The number of vertices of the polygon.
|
324
|
+
center (tuple[int, int]): The center of the polygon.
|
325
|
+
radius (int): The radius of the polygon.
|
326
|
+
rounding_radius (int): The rounding radius of the polygon.
|
327
|
+
|
328
|
+
Returns:
|
329
|
+
list[tuple[int, int]] | None: The rounded polygon.
|
330
|
+
"""
|
331
|
+
angle_offset = np.pi / num_vertices
|
332
|
+
angles = np.linspace(0, 2 * np.pi, num_vertices, endpoint=False) + angle_offset
|
333
|
+
random_angles = angles + np.random.uniform(
|
334
|
+
-ISLAND_DISTORTION, ISLAND_DISTORTION, num_vertices
|
335
|
+
) # Add randomness to angles
|
336
|
+
random_radii = radius + np.random.uniform(
|
337
|
+
-radius * ISLAND_DISTORTION, radius * ISLAND_DISTORTION, num_vertices
|
338
|
+
) # Add randomness to radii
|
339
|
+
|
340
|
+
points = [
|
341
|
+
(center[0] + np.cos(a) * r, center[1] + np.sin(a) * r)
|
342
|
+
for a, r in zip(random_angles, random_radii)
|
343
|
+
]
|
344
|
+
polygon = Polygon(points)
|
345
|
+
buffered_polygon = polygon.buffer(rounding_radius, resolution=16)
|
346
|
+
rounded_polygon = list(buffered_polygon.exterior.coords)
|
347
|
+
if not rounded_polygon:
|
348
|
+
return None
|
349
|
+
return rounded_polygon
|
350
|
+
|
351
|
+
grass_image_copy = grass_image.copy()
|
352
|
+
if forest_image is not None:
|
353
|
+
# Add the forest layer to the base image, to merge the masks.
|
354
|
+
grass_image_copy[forest_image != 0] = 33
|
355
|
+
# Set all the non-zero values to 33.
|
356
|
+
grass_image_copy[grass_image != 0] = 33
|
357
|
+
|
358
|
+
# Add islands of plants to the base image.
|
359
|
+
island_count = self.map_size
|
360
|
+
self.logger.info("Adding %s islands of plants to the base image.", island_count)
|
361
|
+
if self.randomize_plants:
|
362
|
+
grass_image_copy = create_island_of_plants(grass_image_copy, island_count)
|
363
|
+
self.logger.debug("Islands of plants added to the base image.")
|
364
|
+
|
365
|
+
# Sligtly reduce the size of the grass_image, that we'll use as mask.
|
366
|
+
kernel = np.ones((3, 3), np.uint8)
|
367
|
+
grass_image = cv2.erode(grass_image, kernel, iterations=1)
|
368
|
+
|
369
|
+
# Remove the values where the base image has zeros.
|
370
|
+
grass_image_copy[grass_image == 0] = 0
|
371
|
+
self.logger.debug("Removed the values where the base image has zeros.")
|
372
|
+
|
373
|
+
# Set zeros on all sides of the image
|
374
|
+
grass_image_copy[0, :] = 0 # Top side
|
375
|
+
grass_image_copy[-1, :] = 0 # Bottom side
|
376
|
+
grass_image_copy[:, 0] = 0 # Left side
|
377
|
+
grass_image_copy[:, -1] = 0 # Right side
|
378
|
+
|
379
|
+
# Value of 33 represents the base grass plant.
|
380
|
+
# After painting it with base grass, we'll create multiple islands of different plants.
|
381
|
+
# On the final step, we'll remove all the values which in pixels
|
382
|
+
# where zerons in the original base image (so we don't paint grass where it should not be).
|
383
|
+
|
384
|
+
# Three channeled 8-bit image, where non-zero values are the
|
385
|
+
# different types of plants (only in the R channel).
|
386
|
+
density_map_fruits = cv2.imread(density_map_fruit_path, cv2.IMREAD_UNCHANGED)
|
387
|
+
self.logger.debug("Density map for fruits loaded, shape: %s.", density_map_fruits.shape)
|
388
|
+
|
389
|
+
# Put the updated base image as the B channel in the density map.
|
390
|
+
density_map_fruits[:, :, 0] = grass_image_copy
|
391
|
+
self.logger.debug("Updated base image added as the B channel in the density map.")
|
392
|
+
|
393
|
+
# Save the updated density map.
|
394
|
+
# Ensure that order of channels is correct because CV2 uses BGR and we need RGB.
|
395
|
+
density_map_fruits = cv2.cvtColor(density_map_fruits, cv2.COLOR_BGR2RGB)
|
396
|
+
cv2.imwrite(density_map_fruit_path, density_map_fruits)
|
397
|
+
self.logger.info("Updated density map for fruits saved in %s.", density_map_fruit_path)
|
maps4fs/generator/i3d.py
CHANGED
@@ -4,15 +4,23 @@ from __future__ import annotations
|
|
4
4
|
|
5
5
|
import json
|
6
6
|
import os
|
7
|
+
from random import choice, randint, uniform
|
8
|
+
from typing import Generator
|
7
9
|
from xml.etree import ElementTree as ET
|
8
10
|
|
11
|
+
import cv2
|
12
|
+
import numpy as np
|
13
|
+
|
9
14
|
from maps4fs.generator.component import Component
|
15
|
+
from maps4fs.generator.texture import Texture
|
10
16
|
|
11
17
|
DEFAULT_HEIGHT_SCALE = 2000
|
12
18
|
DISPLACEMENT_LAYER_SIZE_FOR_BIG_MAPS = 32768
|
13
19
|
DEFAULT_MAX_LOD_DISTANCE = 10000
|
14
20
|
DEFAULT_MAX_LOD_OCCLUDER_DISTANCE = 10000
|
15
|
-
NODE_ID_STARTING_VALUE =
|
21
|
+
NODE_ID_STARTING_VALUE = 2000
|
22
|
+
TREE_NODE_ID_STARTING_VALUE = 4000
|
23
|
+
DEFAULT_FOREST_DENSITY = 10
|
16
24
|
|
17
25
|
|
18
26
|
# pylint: disable=R0903
|
@@ -44,10 +52,14 @@ class I3d(Component):
|
|
44
52
|
self.logger.info("I3D file processing is not implemented for this game.")
|
45
53
|
self._map_i3d_path = None
|
46
54
|
|
55
|
+
self.forest_density = self.kwargs.get("forest_density", DEFAULT_FOREST_DENSITY)
|
56
|
+
self.logger.info("Forest density: %s.", self.forest_density)
|
57
|
+
|
47
58
|
def process(self) -> None:
|
48
59
|
"""Updates the map I3D file with the default settings."""
|
49
60
|
self._update_i3d_file()
|
50
61
|
self._add_fields()
|
62
|
+
self._add_forests()
|
51
63
|
|
52
64
|
def _get_tree(self) -> ET.ElementTree | None:
|
53
65
|
"""Returns the ElementTree instance of the map I3D file."""
|
@@ -85,6 +97,21 @@ class I3d(Component):
|
|
85
97
|
|
86
98
|
self.logger.debug("TerrainTransformGroup element updated in I3D file.")
|
87
99
|
|
100
|
+
sun_elem = map_elem.find(".//Light[@name='sun']")
|
101
|
+
|
102
|
+
if sun_elem is not None:
|
103
|
+
self.logger.debug("Sun element found in I3D file.")
|
104
|
+
|
105
|
+
distance = self.map_size // 2
|
106
|
+
|
107
|
+
sun_elem.set("lastShadowMapSplitBboxMin", f"-{distance},-128,-{distance}")
|
108
|
+
sun_elem.set("lastShadowMapSplitBboxMax", f"{distance},148,{distance}")
|
109
|
+
|
110
|
+
self.logger.debug(
|
111
|
+
"Sun BBOX updated with half of the map size: %s.",
|
112
|
+
distance,
|
113
|
+
)
|
114
|
+
|
88
115
|
if self.map_size > 4096:
|
89
116
|
displacement_layer = terrain_elem.find(".//DisplacementLayer") # pylint: disable=W0631
|
90
117
|
|
@@ -301,3 +328,138 @@ class I3d(Component):
|
|
301
328
|
attribute_node.set("type", attr_type)
|
302
329
|
attribute_node.set("value", value)
|
303
330
|
return attribute_node
|
331
|
+
|
332
|
+
# pylint: disable=R0911
|
333
|
+
def _add_forests(self) -> None:
|
334
|
+
"""Adds forests to the map I3D file."""
|
335
|
+
try:
|
336
|
+
tree_schema_path = self.game.tree_schema
|
337
|
+
except ValueError:
|
338
|
+
self.logger.warning("Tree schema path not set for the Game %s.", self.game.code)
|
339
|
+
return
|
340
|
+
|
341
|
+
if not os.path.isfile(tree_schema_path):
|
342
|
+
self.logger.warning("Tree schema file was not found: %s.", tree_schema_path)
|
343
|
+
return
|
344
|
+
|
345
|
+
try:
|
346
|
+
with open(tree_schema_path, "r", encoding="utf-8") as tree_schema_file:
|
347
|
+
tree_schema: list[dict[str, str | int]] = json.load(tree_schema_file)
|
348
|
+
except json.JSONDecodeError as e:
|
349
|
+
self.logger.warning(
|
350
|
+
"Could not load tree schema from %s with error: %s", tree_schema_path, e
|
351
|
+
)
|
352
|
+
return
|
353
|
+
|
354
|
+
texture_component: Texture | None = self.map.get_component("Texture") # type: ignore
|
355
|
+
if not texture_component:
|
356
|
+
self.logger.warning("Texture component not found.")
|
357
|
+
return
|
358
|
+
|
359
|
+
forest_layer = texture_component.get_layer_by_usage("forest")
|
360
|
+
|
361
|
+
if not forest_layer:
|
362
|
+
self.logger.warning("Forest layer not found.")
|
363
|
+
return
|
364
|
+
|
365
|
+
weights_directory = self.game.weights_dir_path(self.map_directory)
|
366
|
+
forest_image_path = forest_layer.get_preview_or_path(weights_directory)
|
367
|
+
|
368
|
+
if not forest_image_path or not os.path.isfile(forest_image_path):
|
369
|
+
self.logger.warning("Forest image not found.")
|
370
|
+
return
|
371
|
+
|
372
|
+
tree = self._get_tree()
|
373
|
+
if tree is None:
|
374
|
+
return
|
375
|
+
|
376
|
+
# Find the <Scene> element in the I3D file.
|
377
|
+
root = tree.getroot()
|
378
|
+
scene_node = root.find(".//Scene")
|
379
|
+
if scene_node is None:
|
380
|
+
self.logger.warning("Scene element not found in I3D file.")
|
381
|
+
return
|
382
|
+
|
383
|
+
self.logger.debug("Scene element found in I3D file, starting to add forests.")
|
384
|
+
|
385
|
+
node_id = TREE_NODE_ID_STARTING_VALUE
|
386
|
+
|
387
|
+
# Create <TransformGroup name="trees" translation="0 400 0" nodeId="{node_id}"> element.
|
388
|
+
trees_node = ET.Element("TransformGroup")
|
389
|
+
trees_node.set("name", "trees")
|
390
|
+
trees_node.set("translation", "0 400 0")
|
391
|
+
trees_node.set("nodeId", str(node_id))
|
392
|
+
node_id += 1
|
393
|
+
|
394
|
+
# pylint: disable=no-member
|
395
|
+
forest_image = cv2.imread(forest_image_path, cv2.IMREAD_UNCHANGED)
|
396
|
+
|
397
|
+
tree_count = 0
|
398
|
+
for x, y in self.non_empty_pixels(forest_image, step=self.forest_density):
|
399
|
+
xcs, ycs = self.top_left_coordinates_to_center((x, y))
|
400
|
+
node_id += 1
|
401
|
+
|
402
|
+
rotation = randint(-180, 180)
|
403
|
+
xcs, ycs = self.randomize_coordinates((xcs, ycs), self.forest_density) # type: ignore
|
404
|
+
|
405
|
+
random_tree = choice(tree_schema)
|
406
|
+
tree_name = random_tree["name"]
|
407
|
+
tree_id = random_tree["reference_id"]
|
408
|
+
|
409
|
+
reference_node = ET.Element("ReferenceNode")
|
410
|
+
reference_node.set("name", tree_name) # type: ignore
|
411
|
+
reference_node.set("translation", f"{xcs} 0 {ycs}")
|
412
|
+
reference_node.set("rotation", f"0 {rotation} 0")
|
413
|
+
reference_node.set("referenceId", str(tree_id))
|
414
|
+
reference_node.set("nodeId", str(node_id))
|
415
|
+
|
416
|
+
trees_node.append(reference_node)
|
417
|
+
tree_count += 1
|
418
|
+
|
419
|
+
scene_node.append(trees_node)
|
420
|
+
self.logger.info("Added %s trees to the I3D file.", tree_count)
|
421
|
+
|
422
|
+
tree.write(self._map_i3d_path) # type: ignore
|
423
|
+
self.logger.info("Map I3D file saved to: %s.", self._map_i3d_path)
|
424
|
+
|
425
|
+
@staticmethod
|
426
|
+
def randomize_coordinates(coordinates: tuple[int, int], density: int) -> tuple[float, float]:
|
427
|
+
"""Randomizes the coordinates of the point with the given density.
|
428
|
+
|
429
|
+
Arguments:
|
430
|
+
coordinates (tuple[int, int]): The coordinates of the point.
|
431
|
+
density (int): The density of the randomization.
|
432
|
+
|
433
|
+
Returns:
|
434
|
+
tuple[float, float]: The randomized coordinates of the point.
|
435
|
+
"""
|
436
|
+
MAXIMUM_RELATIVE_SHIFT = 0.2 # pylint: disable=C0103
|
437
|
+
shift_range = density * MAXIMUM_RELATIVE_SHIFT
|
438
|
+
|
439
|
+
x_shift = uniform(-shift_range, shift_range)
|
440
|
+
y_shift = uniform(-shift_range, shift_range)
|
441
|
+
|
442
|
+
x, y = coordinates
|
443
|
+
x += x_shift # type: ignore
|
444
|
+
y += y_shift # type: ignore
|
445
|
+
|
446
|
+
return x, y
|
447
|
+
|
448
|
+
@staticmethod
|
449
|
+
def non_empty_pixels(
|
450
|
+
image: np.ndarray, step: int = 1
|
451
|
+
) -> Generator[tuple[int, int], None, None]:
|
452
|
+
"""Receives numpy array, which represents single-channeled image of uint8 type.
|
453
|
+
Yield coordinates of non-empty pixels (pixels with value greater than 0).
|
454
|
+
|
455
|
+
Arguments:
|
456
|
+
image (np.ndarray): The image to get non-empty pixels from.
|
457
|
+
step (int, optional): The step to iterate through the image. Defaults to 1.
|
458
|
+
|
459
|
+
Yields:
|
460
|
+
tuple[int, int]: The coordinates of non-empty pixels.
|
461
|
+
"""
|
462
|
+
for y, row in enumerate(image[::step]):
|
463
|
+
for x, value in enumerate(row[::step]):
|
464
|
+
if value > 0:
|
465
|
+
yield x * step, y * step
|
maps4fs/generator/map.py
CHANGED
@@ -74,6 +74,7 @@ class Map:
|
|
74
74
|
for game_component in self.game.components:
|
75
75
|
component = game_component(
|
76
76
|
self.game,
|
77
|
+
self,
|
77
78
|
self.coordinates,
|
78
79
|
self.size,
|
79
80
|
self.rotated_size,
|
@@ -82,6 +83,7 @@ class Map:
|
|
82
83
|
self.logger,
|
83
84
|
**self.kwargs,
|
84
85
|
)
|
86
|
+
self.components.append(component)
|
85
87
|
|
86
88
|
yield component.__class__.__name__
|
87
89
|
|
@@ -104,7 +106,20 @@ class Map:
|
|
104
106
|
e,
|
105
107
|
)
|
106
108
|
raise e
|
107
|
-
|
109
|
+
|
110
|
+
def get_component(self, component_name: str) -> Component | None:
|
111
|
+
"""Get component by name.
|
112
|
+
|
113
|
+
Arguments:
|
114
|
+
component_name (str): Name of the component.
|
115
|
+
|
116
|
+
Returns:
|
117
|
+
Component | None: Component instance or None if not found.
|
118
|
+
"""
|
119
|
+
for component in self.components:
|
120
|
+
if component.__class__.__name__ == component_name:
|
121
|
+
return component
|
122
|
+
return None
|
108
123
|
|
109
124
|
def previews(self) -> list[str]:
|
110
125
|
"""Get list of preview images.
|
maps4fs/generator/texture.py
CHANGED
@@ -63,6 +63,8 @@ class Texture(Component):
|
|
63
63
|
exclude_weight: bool = False,
|
64
64
|
priority: int | None = None,
|
65
65
|
info_layer: str | None = None,
|
66
|
+
usage: str | None = None,
|
67
|
+
background: bool = False,
|
66
68
|
):
|
67
69
|
self.name = name
|
68
70
|
self.count = count
|
@@ -72,6 +74,8 @@ class Texture(Component):
|
|
72
74
|
self.exclude_weight = exclude_weight
|
73
75
|
self.priority = priority
|
74
76
|
self.info_layer = info_layer
|
77
|
+
self.usage = usage
|
78
|
+
self.background = background
|
75
79
|
|
76
80
|
def to_json(self) -> dict[str, str | list[str] | bool]: # type: ignore
|
77
81
|
"""Returns dictionary with layer data.
|
@@ -87,6 +91,8 @@ class Texture(Component):
|
|
87
91
|
"exclude_weight": self.exclude_weight,
|
88
92
|
"priority": self.priority,
|
89
93
|
"info_layer": self.info_layer,
|
94
|
+
"usage": self.usage,
|
95
|
+
"background": self.background,
|
90
96
|
}
|
91
97
|
|
92
98
|
data = {k: v for k, v in data.items() if v is not None}
|
@@ -117,29 +123,29 @@ class Texture(Component):
|
|
117
123
|
weight_postfix = "_weight" if not self.exclude_weight else ""
|
118
124
|
return os.path.join(weights_directory, f"{self.name}{idx}{weight_postfix}.png")
|
119
125
|
|
120
|
-
def path_preview(self,
|
126
|
+
def path_preview(self, weights_directory: str) -> str:
|
121
127
|
"""Returns path to the preview of the first texture of the layer.
|
122
128
|
|
123
129
|
Arguments:
|
124
|
-
|
130
|
+
weights_directory (str): Path to the directory with weights.
|
125
131
|
|
126
132
|
Returns:
|
127
133
|
str: Path to the preview.
|
128
134
|
"""
|
129
|
-
return self.path(
|
135
|
+
return self.path(weights_directory).replace(".png", "_preview.png")
|
130
136
|
|
131
|
-
def get_preview_or_path(self,
|
137
|
+
def get_preview_or_path(self, weights_directory: str) -> str:
|
132
138
|
"""Returns path to the preview of the first texture of the layer if it exists,
|
133
139
|
otherwise returns path to the texture.
|
134
140
|
|
135
141
|
Arguments:
|
136
|
-
|
142
|
+
weights_directory (str): Path to the directory with weights.
|
137
143
|
|
138
144
|
Returns:
|
139
145
|
str: Path to the preview or texture.
|
140
146
|
"""
|
141
|
-
preview_path = self.path_preview(
|
142
|
-
return preview_path if os.path.isfile(preview_path) else self.path(
|
147
|
+
preview_path = self.path_preview(weights_directory)
|
148
|
+
return preview_path if os.path.isfile(preview_path) else self.path(weights_directory)
|
143
149
|
|
144
150
|
def paths(self, weights_directory: str) -> list[str]:
|
145
151
|
"""Returns a list of paths to the textures of the layer.
|
@@ -175,17 +181,30 @@ class Texture(Component):
|
|
175
181
|
self.fields_padding = self.kwargs.get("fields_padding", 0)
|
176
182
|
self.logger.debug("Light version: %s.", self.light_version)
|
177
183
|
|
178
|
-
|
179
|
-
|
184
|
+
self.custom_schema: list[dict[str, str | dict[str, str] | int]] | None = self.kwargs.get(
|
185
|
+
"custom_schema"
|
186
|
+
)
|
180
187
|
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
188
|
+
if self.custom_schema:
|
189
|
+
layers_schema = self.custom_schema
|
190
|
+
self.logger.info("Custom schema loaded with %s layers.", len(layers_schema))
|
191
|
+
else:
|
192
|
+
if not os.path.isfile(self.game.texture_schema):
|
193
|
+
raise FileNotFoundError(
|
194
|
+
f"Texture layers schema not found: {self.game.texture_schema}"
|
195
|
+
)
|
186
196
|
|
187
|
-
|
188
|
-
|
197
|
+
try:
|
198
|
+
with open(self.game.texture_schema, "r", encoding="utf-8") as f:
|
199
|
+
layers_schema = json.load(f)
|
200
|
+
except json.JSONDecodeError as e:
|
201
|
+
raise ValueError(f"Error loading texture layers schema: {e}") from e
|
202
|
+
|
203
|
+
try:
|
204
|
+
self.layers = [self.Layer.from_json(layer) for layer in layers_schema]
|
205
|
+
self.logger.info("Loaded %s layers.", len(self.layers))
|
206
|
+
except Exception as e: # pylint: disable=W0703
|
207
|
+
raise ValueError(f"Error loading texture layers: {e}") from e
|
189
208
|
|
190
209
|
base_layer = self.get_base_layer()
|
191
210
|
if base_layer:
|
@@ -212,6 +231,28 @@ class Texture(Component):
|
|
212
231
|
return layer
|
213
232
|
return None
|
214
233
|
|
234
|
+
def get_background_layers(self) -> list[Layer]:
|
235
|
+
"""Returns list of background layers.
|
236
|
+
|
237
|
+
Returns:
|
238
|
+
list[Layer]: List of background layers.
|
239
|
+
"""
|
240
|
+
return [layer for layer in self.layers if layer.background]
|
241
|
+
|
242
|
+
def get_layer_by_usage(self, usage: str) -> Layer | None:
|
243
|
+
"""Returns layer by usage.
|
244
|
+
|
245
|
+
Arguments:
|
246
|
+
usage (str): Usage of the layer.
|
247
|
+
|
248
|
+
Returns:
|
249
|
+
Layer | None: Layer.
|
250
|
+
"""
|
251
|
+
for layer in self.layers:
|
252
|
+
if layer.usage == usage:
|
253
|
+
return layer
|
254
|
+
return None
|
255
|
+
|
215
256
|
def process(self):
|
216
257
|
self._prepare_weights()
|
217
258
|
self._read_parameters()
|
@@ -287,7 +328,10 @@ class Texture(Component):
|
|
287
328
|
Arguments:
|
288
329
|
layer (Layer): Layer with textures and tags.
|
289
330
|
"""
|
290
|
-
|
331
|
+
if layer.tags is None:
|
332
|
+
size = (self.map_size, self.map_size)
|
333
|
+
else:
|
334
|
+
size = (self.map_rotated_size, self.map_rotated_size)
|
291
335
|
postfix = "_weight.png" if not layer.exclude_weight else ".png"
|
292
336
|
if layer.count == 0:
|
293
337
|
filepaths = [os.path.join(self._weights_dir, layer.name + postfix)]
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: maps4fs
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.2.4
|
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
|
@@ -43,7 +43,7 @@ Requires-Dist: pympler
|
|
43
43
|
<a href="#Background-terrain">Background terrain</a> •
|
44
44
|
<a href="#Overview-image">Overview image</a><br>
|
45
45
|
<a href="#DDS-conversion">DDS conversion</a> •
|
46
|
-
<a href="#
|
46
|
+
<a href="#Advanced-settings">Advanced settings</a> •
|
47
47
|
<a href="#Resources">Resources</a> •
|
48
48
|
<a href="#Bugs-and-feature-requests">Bugs and feature requests</a><br>
|
49
49
|
<a href="#Special-thanks">Special thanks</a>
|
@@ -68,6 +68,9 @@ Requires-Dist: pympler
|
|
68
68
|
🔄 Support map rotation 🆕<br>
|
69
69
|
🌾 Automatically generates fields 🆕<br>
|
70
70
|
🌽 Automatically generates farmlands 🆕<br>
|
71
|
+
🌿 Automatically generates decorative foliage 🆕<br>
|
72
|
+
🌲 Automatically generates forests 🆕<br>
|
73
|
+
🌊 Automatically generates water planes 🆕<br>
|
71
74
|
🌍 Based on real-world data from OpenStreetMap<br>
|
72
75
|
🏞️ Generates height map using SRTM dataset<br>
|
73
76
|
📦 Provides a ready-to-use map template for the Giants Editor<br>
|
@@ -84,6 +87,12 @@ Requires-Dist: pympler
|
|
84
87
|
🛰️ Realistic background terrain with satellite images.<br><br>
|
85
88
|
<img src="https://github.com/user-attachments/assets/6e3c0e99-2cce-46ac-82db-5cb60bba7a30"><br>
|
86
89
|
📐 Perfectly aligned background terrain.<br><br>
|
90
|
+
<img src="https://github.com/user-attachments/assets/5764b2ec-e626-426f-9f5d-beb12ba95133"><br>
|
91
|
+
🌿 Automatically generates decorative foliage.<br><br>
|
92
|
+
<img src="https://github.com/user-attachments/assets/27a5e541-a9f5-4504-b8d2-64aae9fb3e52"><br>
|
93
|
+
🌲 Automatically generates forests.<br><br>
|
94
|
+
<img src="https://github.com/user-attachments/assets/cce7d4e0-cba2-4dd2-b22d-03137fb2e860"><br>
|
95
|
+
🌊 Automatically generates water planes.<br><br>
|
87
96
|
<img src="https://github.com/user-attachments/assets/80e5923c-22c7-4dc0-8906-680902511f3a"><br>
|
88
97
|
🗒️ True-to-life blueprints for fast and precise modding.<br><br>
|
89
98
|
<img width="480" src="https://github.com/user-attachments/assets/1a8802d2-6a3b-4bfa-af2b-7c09478e199b"><br>
|
@@ -95,6 +104,9 @@ Requires-Dist: pympler
|
|
95
104
|
<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>
|
96
105
|
<p align="center"><i>How to Generate a Map for Farming Simulator 25 and 22 from a real place using maps4FS.</i></p>
|
97
106
|
|
107
|
+

|
108
|
+
<p align="center"><i>Map example generated with maps4fs.</i></p>
|
109
|
+
|
98
110
|
## Quick Start
|
99
111
|
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>
|
100
112
|
### 🚜 For most users
|
@@ -453,7 +465,7 @@ List of the important DDS files:
|
|
453
465
|
- `preview.dds` - 2048x2048 pixels, the preview image of the map on the loading screen,
|
454
466
|
- `mapsUS/overview.dds` - 4096x4096 pixels, the overview image of the map (in-game map)
|
455
467
|
|
456
|
-
##
|
468
|
+
## Advanced settings
|
457
469
|
The tool supports the custom size of the map. To use this feature select `Custom` in the `Map size` dropdown and enter the desired size. The tool will generate a map with the size you entered.<br>
|
458
470
|
|
459
471
|
⛔️ Do not use this feature, if you don't know what you're doing. In most cases, the Giants Editor will just crash on opening the file, because you need to enter specific values for the map size.<br><br>
|
@@ -470,6 +482,8 @@ You can also apply some advanced settings to the map generation process. Note th
|
|
470
482
|
|
471
483
|
- Plateau height: this value will be added to each pixel of the DEM image, making it "higher". It's useful when you want to add some negative heights on the map, that appear to be in a "low" place. By default, it's set to 0.
|
472
484
|
|
485
|
+
- Water depth: this value will be subtracted from each pixel of the DEM image, where water resources are located. Pay attention that it's not in meters, instead it in the pixel value of DEM, which is 16 bit image with possible values from 0 to 65535. When this value is set, the same value will be added to the plateau setting to avoid negative heights.
|
486
|
+
|
473
487
|
### Texture Advanced settings
|
474
488
|
|
475
489
|
- 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.
|
@@ -478,6 +492,12 @@ You can also apply some advanced settings to the map generation process. Note th
|
|
478
492
|
|
479
493
|
- 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.
|
480
494
|
|
495
|
+
### Vegetation Advanced settings
|
496
|
+
|
497
|
+
- Forest density - the density of the forest in meters. The lower the value, the lower the distance between the trees, which makes the forest denser. Note, that low values will lead to enormous number of trees, which may cause the Giants Editor to crash or lead to performance issues. By default, it's set to 10.
|
498
|
+
|
499
|
+
- Random plants - when adding decorative foliage, enabling this option will add different species of plants to the map. If unchecked only basic grass (smallDenseMix) will be added. Defaults to True.
|
500
|
+
|
481
501
|
## Resources
|
482
502
|
In this section, you'll find a list of the resources that you need to create a map for the Farming Simulator.<br>
|
483
503
|
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=da4jmND2Ths9AffnkAKgzLHNkvKFOc_l21gJisPXqWY,155
|
2
|
+
maps4fs/logger.py,sha256=B-NEYpMjPAAqlV4VpfTi6nbBFnEABVtQOaYe6nMpidg,1489
|
3
|
+
maps4fs/generator/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
|
4
|
+
maps4fs/generator/background.py,sha256=21xB-xn2A6QGdX9UVWqvzoW-L6JWPAZOqCcIhR8nxKU,22689
|
5
|
+
maps4fs/generator/component.py,sha256=SeI1xfwo9I4lrkcOcHyjxMffHsG8OXc80-mNsR2zpPw,17748
|
6
|
+
maps4fs/generator/config.py,sha256=b7qY0luC-_WM_c72Ohtlf4FrB37X5cALInbestSdUsw,4382
|
7
|
+
maps4fs/generator/dem.py,sha256=rc7ADzjvlZzStOqagsWW0Vrm9-X86aPpoR1RhBF_-OE,16025
|
8
|
+
maps4fs/generator/game.py,sha256=ZQeYzPzPB3CG41avdhNCyTZpHEeedqNBuAbNevTZuXg,7931
|
9
|
+
maps4fs/generator/grle.py,sha256=3hcr5e2YLXemFi-_x2cLHWbMVb06591k0PZxaBVovH8,17600
|
10
|
+
maps4fs/generator/i3d.py,sha256=oK5pKjzvT-gydma5Q6CcDYTVODGxK7MIGajLrAV9JkU,18370
|
11
|
+
maps4fs/generator/map.py,sha256=lA1MNAcMwsDtsYxbwwm7DjwP3zraHKnri_xnLUu30j0,5326
|
12
|
+
maps4fs/generator/qgis.py,sha256=Es8hLuqN_KH8lDfnJE6He2rWYbAKJ3RGPn-o87S6CPI,6116
|
13
|
+
maps4fs/generator/texture.py,sha256=vgiwJNIl14JABhNOBGh_W8SBkAUNQN3TjNJayR76va0,27468
|
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.2.4.dist-info/LICENSE.md,sha256=pTKD_oUexcn-yccFCTrMeLkZy0ifLRa-VNcDLqLZaIw,10749
|
18
|
+
maps4fs-1.2.4.dist-info/METADATA,sha256=cR3704tYCx9AaL8NW5cLrkMijvDNyWdxT7CpCaTv9NE,30600
|
19
|
+
maps4fs-1.2.4.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
20
|
+
maps4fs-1.2.4.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
|
21
|
+
maps4fs-1.2.4.dist-info/RECORD,,
|
maps4fs-1.1.6.dist-info/RECORD
DELETED
@@ -1,21 +0,0 @@
|
|
1
|
-
maps4fs/__init__.py,sha256=da4jmND2Ths9AffnkAKgzLHNkvKFOc_l21gJisPXqWY,155
|
2
|
-
maps4fs/logger.py,sha256=B-NEYpMjPAAqlV4VpfTi6nbBFnEABVtQOaYe6nMpidg,1489
|
3
|
-
maps4fs/generator/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
|
4
|
-
maps4fs/generator/background.py,sha256=dnUkS1atEqqz_ryKkcfP_K9ZcwMHZVs97y-twZMpD44,15881
|
5
|
-
maps4fs/generator/component.py,sha256=sihh2S35q0o38leEU-dpi0is6kYCuxXiWUISAtiQErM,17351
|
6
|
-
maps4fs/generator/config.py,sha256=b7qY0luC-_WM_c72Ohtlf4FrB37X5cALInbestSdUsw,4382
|
7
|
-
maps4fs/generator/dem.py,sha256=rc7ADzjvlZzStOqagsWW0Vrm9-X86aPpoR1RhBF_-OE,16025
|
8
|
-
maps4fs/generator/game.py,sha256=2oA77i9lyNYOlhtOZft6KiPTxgGJbX1Gam1TosMNLis,7407
|
9
|
-
maps4fs/generator/grle.py,sha256=IVbvzF_azItxLE_ZxaM_suQflEZjJuMgHmv1En51G7A,7592
|
10
|
-
maps4fs/generator/i3d.py,sha256=SzjAxYacbBQ030N2sHh9c-dhWoG3iADJqCrHBox6vWI,12268
|
11
|
-
maps4fs/generator/map.py,sha256=7UqLjDZgoY6M0ZxX5Q4Rjee2UGWZ64a3tGyr8B24UO0,4863
|
12
|
-
maps4fs/generator/qgis.py,sha256=Es8hLuqN_KH8lDfnJE6He2rWYbAKJ3RGPn-o87S6CPI,6116
|
13
|
-
maps4fs/generator/texture.py,sha256=uSt563KomSVUndl25IgEIi0YuhBQbnhPIoQKa-4A3_E,26016
|
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.1.6.dist-info/LICENSE.md,sha256=pTKD_oUexcn-yccFCTrMeLkZy0ifLRa-VNcDLqLZaIw,10749
|
18
|
-
maps4fs-1.1.6.dist-info/METADATA,sha256=W5x23vIOjkx-xxq4L0VIWVIA9pCgOniTQvbFqiTUVFs,28942
|
19
|
-
maps4fs-1.1.6.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
20
|
-
maps4fs-1.1.6.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
|
21
|
-
maps4fs-1.1.6.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|