maps4fs 1.8.1__py3-none-any.whl → 1.8.12__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- maps4fs/generator/background.py +7 -11
- maps4fs/generator/component/__init__.py +1 -0
- maps4fs/generator/component/base/__init__.py +1 -0
- maps4fs/generator/{component.py → component/base/component.py} +39 -23
- maps4fs/generator/component/base/component_xml.py +95 -0
- maps4fs/generator/{config.py → component/config.py} +15 -30
- maps4fs/generator/component/i3d.py +545 -0
- maps4fs/generator/dem.py +1 -10
- maps4fs/generator/dtm/srtm.py +0 -2
- maps4fs/generator/game.py +33 -2
- maps4fs/generator/grle.py +10 -16
- maps4fs/generator/map.py +41 -1
- maps4fs/generator/satellite.py +1 -2
- maps4fs/generator/settings.py +11 -0
- maps4fs/generator/texture.py +5 -7
- maps4fs/toolbox/background.py +1 -3
- {maps4fs-1.8.1.dist-info → maps4fs-1.8.12.dist-info}/METADATA +5 -1
- maps4fs-1.8.12.dist-info/RECORD +39 -0
- maps4fs/generator/i3d.py +0 -624
- maps4fs-1.8.1.dist-info/RECORD +0 -36
- {maps4fs-1.8.1.dist-info → maps4fs-1.8.12.dist-info}/LICENSE.md +0 -0
- {maps4fs-1.8.1.dist-info → maps4fs-1.8.12.dist-info}/WHEEL +0 -0
- {maps4fs-1.8.1.dist-info → maps4fs-1.8.12.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,545 @@
|
|
1
|
+
"""This module contains the Config class for map settings and configuration."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import json
|
6
|
+
import os
|
7
|
+
from random import choice, randint, uniform
|
8
|
+
from typing import Generator
|
9
|
+
from xml.etree import ElementTree as ET
|
10
|
+
|
11
|
+
import cv2
|
12
|
+
import numpy as np
|
13
|
+
from tqdm import tqdm
|
14
|
+
|
15
|
+
from maps4fs.generator.component.base.component_xml import XMLComponent
|
16
|
+
from maps4fs.generator.settings import Parameters
|
17
|
+
|
18
|
+
MAP_SIZE_LIMIT_FOR_DISPLACEMENT_LAYER = 4096
|
19
|
+
DISPLACEMENT_LAYER_SIZE_FOR_BIG_MAPS = 32768
|
20
|
+
NODE_ID_STARTING_VALUE = 2000
|
21
|
+
SPLINES_NODE_ID_STARTING_VALUE = 5000
|
22
|
+
TREE_NODE_ID_STARTING_VALUE = 10000
|
23
|
+
TREES_DEFAULT_Z_VALUE = 400
|
24
|
+
|
25
|
+
FIELDS_ATTRIBUTES = [
|
26
|
+
("angle", "integer", "0"),
|
27
|
+
("missionAllowed", "boolean", "true"),
|
28
|
+
("missionOnlyGrass", "boolean", "false"),
|
29
|
+
("nameIndicatorIndex", "string", "1"),
|
30
|
+
("polygonIndex", "string", "0"),
|
31
|
+
("teleportIndicatorIndex", "string", "2"),
|
32
|
+
]
|
33
|
+
|
34
|
+
|
35
|
+
class I3d(XMLComponent):
|
36
|
+
"""Component for map i3d file settings and configuration.
|
37
|
+
|
38
|
+
Arguments:
|
39
|
+
game (Game): The game instance for which the map is generated.
|
40
|
+
coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
|
41
|
+
map_size (int): The size of the map in pixels.
|
42
|
+
map_rotated_size (int): The size of the map in pixels after rotation.
|
43
|
+
rotation (int): The rotation angle of the map.
|
44
|
+
map_directory (str): The directory where the map files are stored.
|
45
|
+
logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
|
46
|
+
info, warning. If not provided, default logging will be used.
|
47
|
+
"""
|
48
|
+
|
49
|
+
def preprocess(self) -> None:
|
50
|
+
"""Gets the path to the map I3D file from the game instance and saves it to the instance
|
51
|
+
attribute. If the game does not support I3D files, the attribute is set to None."""
|
52
|
+
self.xml_path = self.game.i3d_file_path(self.map_directory)
|
53
|
+
|
54
|
+
def process(self) -> None:
|
55
|
+
"""Updates the map I3D file and creates splines in a separate I3D file."""
|
56
|
+
self.update_height_scale()
|
57
|
+
|
58
|
+
self._update_parameters()
|
59
|
+
|
60
|
+
if self.game.i3d_processing:
|
61
|
+
self._add_fields()
|
62
|
+
self._add_forests()
|
63
|
+
self._add_splines()
|
64
|
+
|
65
|
+
def update_height_scale(self, value: int | None = None) -> None:
|
66
|
+
"""Updates the height scale value in the map I3D file.
|
67
|
+
If the value is not provided, the method checks if the shared settings are set to change
|
68
|
+
the height scale and if the height scale value is set. If not, the method returns without
|
69
|
+
updating the height scale.
|
70
|
+
|
71
|
+
Arguments:
|
72
|
+
value (int, optional): The height scale value.
|
73
|
+
"""
|
74
|
+
if not value:
|
75
|
+
if (
|
76
|
+
self.map.shared_settings.change_height_scale
|
77
|
+
and self.map.shared_settings.height_scale_value
|
78
|
+
):
|
79
|
+
value = int(self.map.shared_settings.height_scale_value)
|
80
|
+
else:
|
81
|
+
return
|
82
|
+
|
83
|
+
tree = self.get_tree()
|
84
|
+
root = tree.getroot()
|
85
|
+
path = ".//Scene/TerrainTransformGroup"
|
86
|
+
|
87
|
+
data = {"heightScale": str(value)}
|
88
|
+
|
89
|
+
self.get_and_update_element(root, path, data)
|
90
|
+
|
91
|
+
def _update_parameters(self) -> None:
|
92
|
+
"""Updates the map I3D file with the sun bounding box and displacement layer size."""
|
93
|
+
|
94
|
+
tree = self.get_tree()
|
95
|
+
root = tree.getroot()
|
96
|
+
|
97
|
+
sun_element_path = ".//Scene/Light[@name='sun']"
|
98
|
+
distance = self.map_size // 2
|
99
|
+
data = {
|
100
|
+
"lastShadowMapSplitBboxMin": f"-{distance},-128,-{distance}",
|
101
|
+
"lastShadowMapSplitBboxMax": f"{distance},148,{distance}",
|
102
|
+
}
|
103
|
+
|
104
|
+
self.get_and_update_element(root, sun_element_path, data)
|
105
|
+
|
106
|
+
if self.map_size > MAP_SIZE_LIMIT_FOR_DISPLACEMENT_LAYER:
|
107
|
+
displacement_layer_path = ".//Scene/TerrainTransformGroup/DisplacementLayer"
|
108
|
+
data = {"size": str(DISPLACEMENT_LAYER_SIZE_FOR_BIG_MAPS)}
|
109
|
+
|
110
|
+
self.get_and_update_element(root, displacement_layer_path, data)
|
111
|
+
|
112
|
+
self.save_tree(tree)
|
113
|
+
|
114
|
+
def _add_splines(self) -> None:
|
115
|
+
"""Adds splines to the map I3D file."""
|
116
|
+
splines_i3d_path = self.game.splines_file_path(self.map_directory)
|
117
|
+
if not os.path.isfile(splines_i3d_path):
|
118
|
+
self.logger.warning("Splines I3D file not found: %s.", splines_i3d_path)
|
119
|
+
return
|
120
|
+
|
121
|
+
tree = self.get_tree(splines_i3d_path)
|
122
|
+
|
123
|
+
roads_polylines = self.get_infolayer_data(Parameters.TEXTURES, Parameters.ROADS_POLYLINES)
|
124
|
+
if not roads_polylines:
|
125
|
+
self.logger.warning("Roads polylines data not found in textures info layer.")
|
126
|
+
return
|
127
|
+
|
128
|
+
root = tree.getroot()
|
129
|
+
# Find <Shapes> element in the I3D file.
|
130
|
+
shapes_node = root.find(".//Shapes")
|
131
|
+
# Find <Scene> element in the I3D file.
|
132
|
+
scene_node = root.find(".//Scene")
|
133
|
+
|
134
|
+
if shapes_node is None or scene_node is None:
|
135
|
+
self.logger.warning("Shapes or Scene node not found in I3D file.")
|
136
|
+
return
|
137
|
+
|
138
|
+
# Read the not resized DEM to obtain Z values for spline points.
|
139
|
+
background_component = self.map.get_background_component()
|
140
|
+
if not background_component:
|
141
|
+
self.logger.warning("Background component not found.")
|
142
|
+
return
|
143
|
+
|
144
|
+
not_resized_dem = cv2.imread(background_component.not_resized_path, cv2.IMREAD_UNCHANGED)
|
145
|
+
if not_resized_dem is None:
|
146
|
+
self.logger.warning("Not resized DEM not found.")
|
147
|
+
return
|
148
|
+
dem_x_size, dem_y_size = not_resized_dem.shape
|
149
|
+
|
150
|
+
user_attributes_node = root.find(".//UserAttributes")
|
151
|
+
if user_attributes_node is None:
|
152
|
+
self.logger.warning("UserAttributes node not found in I3D file.")
|
153
|
+
return
|
154
|
+
|
155
|
+
node_id = SPLINES_NODE_ID_STARTING_VALUE
|
156
|
+
for road_id, road in enumerate(roads_polylines, start=1):
|
157
|
+
# Add to scene node
|
158
|
+
# <Shape name="spline01_CSV" translation="0 0 0" nodeId="11" shapeId="11"/>
|
159
|
+
|
160
|
+
try:
|
161
|
+
fitted_road = self.fit_object_into_bounds(
|
162
|
+
linestring_points=road, angle=self.rotation
|
163
|
+
)
|
164
|
+
except ValueError as e:
|
165
|
+
self.logger.debug(
|
166
|
+
"Road %s could not be fitted into the map bounds with error: %s",
|
167
|
+
road_id,
|
168
|
+
e,
|
169
|
+
)
|
170
|
+
continue
|
171
|
+
|
172
|
+
fitted_road = self.interpolate_points(
|
173
|
+
fitted_road, num_points=self.map.spline_settings.spline_density
|
174
|
+
)
|
175
|
+
|
176
|
+
spline_name = f"spline{road_id}"
|
177
|
+
|
178
|
+
data = {
|
179
|
+
"name": spline_name,
|
180
|
+
"translation": "0 0 0",
|
181
|
+
"nodeId": str(node_id),
|
182
|
+
"shapeId": str(node_id),
|
183
|
+
}
|
184
|
+
|
185
|
+
scene_node.append(self.create_element("Shape", data))
|
186
|
+
|
187
|
+
road_ccs = [self.top_left_coordinates_to_center(point) for point in fitted_road]
|
188
|
+
|
189
|
+
data = {
|
190
|
+
"name": spline_name,
|
191
|
+
"shapeId": str(node_id),
|
192
|
+
"degree": "3",
|
193
|
+
"form": "open",
|
194
|
+
}
|
195
|
+
nurbs_curve_node = self.create_element("NurbsCurve", data)
|
196
|
+
|
197
|
+
for point_ccs, point in zip(road_ccs, fitted_road):
|
198
|
+
cx, cy = point_ccs
|
199
|
+
x, y = point
|
200
|
+
|
201
|
+
x = max(0, min(int(x), dem_x_size - 1))
|
202
|
+
y = max(0, min(int(y), dem_y_size - 1))
|
203
|
+
|
204
|
+
z = not_resized_dem[y, x]
|
205
|
+
z *= self.get_z_scaling_factor()
|
206
|
+
|
207
|
+
nurbs_curve_node.append(self.create_element("cv", {"c": f"{cx}, {z}, {cy}"}))
|
208
|
+
|
209
|
+
shapes_node.append(nurbs_curve_node)
|
210
|
+
|
211
|
+
user_attribute_node = self.get_user_attribute_node(
|
212
|
+
node_id,
|
213
|
+
attributes=[
|
214
|
+
("maxSpeedScale", "integer", "1"),
|
215
|
+
("speedLimit", "integer", "100"),
|
216
|
+
],
|
217
|
+
)
|
218
|
+
|
219
|
+
user_attributes_node.append(user_attribute_node)
|
220
|
+
node_id += 1
|
221
|
+
|
222
|
+
tree.write(splines_i3d_path) # type: ignore
|
223
|
+
self.logger.debug("Splines I3D file saved to: %s.", splines_i3d_path)
|
224
|
+
|
225
|
+
def _add_fields(self) -> None:
|
226
|
+
"""Adds fields to the map I3D file."""
|
227
|
+
tree = self.get_tree()
|
228
|
+
|
229
|
+
border = 0
|
230
|
+
fields_layer = self.map.get_texture_layer(by_usage=Parameters.FIELD)
|
231
|
+
if fields_layer and fields_layer.border:
|
232
|
+
border = fields_layer.border
|
233
|
+
|
234
|
+
fields = self.get_infolayer_data(Parameters.TEXTURES, Parameters.FIELDS)
|
235
|
+
if not fields:
|
236
|
+
self.logger.warning("Fields data not found in textures info layer.")
|
237
|
+
return
|
238
|
+
|
239
|
+
self.logger.debug("Found %s fields in textures info layer.", len(fields))
|
240
|
+
self.logger.debug("Starging to add fields to the I3D file.")
|
241
|
+
|
242
|
+
root = tree.getroot()
|
243
|
+
gameplay_node = root.find(".//TransformGroup[@name='gameplay']")
|
244
|
+
|
245
|
+
if gameplay_node is None:
|
246
|
+
return
|
247
|
+
fields_node = gameplay_node.find(".//TransformGroup[@name='fields']")
|
248
|
+
user_attributes_node = root.find(".//UserAttributes")
|
249
|
+
|
250
|
+
if fields_node is None or user_attributes_node is None:
|
251
|
+
return
|
252
|
+
|
253
|
+
node_id = NODE_ID_STARTING_VALUE
|
254
|
+
field_id = 1
|
255
|
+
|
256
|
+
for field in tqdm(fields, desc="Adding fields", unit="field"):
|
257
|
+
try:
|
258
|
+
fitted_field = self.fit_object_into_bounds(
|
259
|
+
polygon_points=field, angle=self.rotation, border=border
|
260
|
+
)
|
261
|
+
except ValueError as e:
|
262
|
+
self.logger.debug(
|
263
|
+
"Field %s could not be fitted into the map bounds with error: %s",
|
264
|
+
field_id,
|
265
|
+
e,
|
266
|
+
)
|
267
|
+
continue
|
268
|
+
|
269
|
+
field_ccs = [self.top_left_coordinates_to_center(point) for point in fitted_field]
|
270
|
+
|
271
|
+
field_node, updated_node_id = self._get_field_xml_entry(field_id, field_ccs, node_id)
|
272
|
+
if field_node is None:
|
273
|
+
continue
|
274
|
+
user_attributes_node.append(
|
275
|
+
self.get_user_attribute_node(node_id, attributes=FIELDS_ATTRIBUTES)
|
276
|
+
)
|
277
|
+
node_id = updated_node_id
|
278
|
+
|
279
|
+
# Adding the field node to the fields node.
|
280
|
+
fields_node.append(field_node)
|
281
|
+
self.logger.debug("Field %s added to the I3D file.", field_id)
|
282
|
+
|
283
|
+
node_id += 1
|
284
|
+
field_id += 1
|
285
|
+
|
286
|
+
self.save_tree(tree)
|
287
|
+
|
288
|
+
def _get_field_xml_entry(
|
289
|
+
self, field_id: int, field_ccs: list[tuple[int, int]], node_id: int
|
290
|
+
) -> tuple[ET.Element, int] | tuple[None, int]:
|
291
|
+
"""Creates an XML entry for the field with given field ID and field coordinates.
|
292
|
+
|
293
|
+
Arguments:
|
294
|
+
field_id (int): The ID of the field.
|
295
|
+
field_ccs (list[tuple[int, int]]): The coordinates of the field polygon points
|
296
|
+
in the center coordinate system.
|
297
|
+
node_id (int): The node ID of the field node.
|
298
|
+
|
299
|
+
Returns:
|
300
|
+
tuple[ET.Element, int] | tuple[None, int]: The field node and the updated node ID or
|
301
|
+
None and the node ID.
|
302
|
+
"""
|
303
|
+
try:
|
304
|
+
cx, cy = self.get_polygon_center(field_ccs)
|
305
|
+
except Exception as e:
|
306
|
+
self.logger.debug("Field %s could not be fitted into the map bounds.", field_id)
|
307
|
+
self.logger.debug("Error: %s", e)
|
308
|
+
return None, node_id
|
309
|
+
|
310
|
+
# Creating the main field node.
|
311
|
+
data = {
|
312
|
+
"name": f"field{field_id}",
|
313
|
+
"translation": f"{cx} 0 {cy}",
|
314
|
+
"nodeId": str(node_id),
|
315
|
+
}
|
316
|
+
field_node = self.create_element("TransformGroup", data)
|
317
|
+
node_id += 1
|
318
|
+
|
319
|
+
# Creating the polygon points node, which contains the points of the field.
|
320
|
+
polygon_points_node = self.create_element(
|
321
|
+
"TransformGroup", {"name": "polygonPoints", "nodeId": str(node_id)}
|
322
|
+
)
|
323
|
+
node_id += 1
|
324
|
+
|
325
|
+
for point_id, point in enumerate(field_ccs, start=1):
|
326
|
+
rx, ry = self.absolute_to_relative(point, (cx, cy))
|
327
|
+
|
328
|
+
node_id += 1
|
329
|
+
point_node = self.create_element(
|
330
|
+
"TransformGroup",
|
331
|
+
{
|
332
|
+
"name": f"point{point_id}",
|
333
|
+
"translation": f"{rx} 0 {ry}",
|
334
|
+
"nodeId": str(node_id),
|
335
|
+
},
|
336
|
+
)
|
337
|
+
|
338
|
+
polygon_points_node.append(point_node)
|
339
|
+
|
340
|
+
field_node.append(polygon_points_node)
|
341
|
+
|
342
|
+
# Adding the name indicator node to the field node.
|
343
|
+
name_indicator_node, node_id = self._get_name_indicator_node(node_id, field_id)
|
344
|
+
field_node.append(name_indicator_node)
|
345
|
+
|
346
|
+
node_id += 1
|
347
|
+
field_node.append(
|
348
|
+
self.create_element(
|
349
|
+
"TransformGroup", {"name": "teleportIndicator", "nodeId": str(node_id)}
|
350
|
+
)
|
351
|
+
)
|
352
|
+
|
353
|
+
return field_node, node_id
|
354
|
+
|
355
|
+
def _get_name_indicator_node(self, node_id: int, field_id: int) -> tuple[ET.Element, int]:
|
356
|
+
"""Creates a name indicator node with given node ID and field ID.
|
357
|
+
|
358
|
+
Arguments:
|
359
|
+
node_id (int): The node ID of the name indicator node.
|
360
|
+
field_id (int): The ID of the field.
|
361
|
+
|
362
|
+
Returns:
|
363
|
+
tuple[ET.Element, int]: The name indicator node and the updated node ID.
|
364
|
+
"""
|
365
|
+
node_id += 1
|
366
|
+
name_indicator_node = self.create_element(
|
367
|
+
"TransformGroup", {"name": "nameIndicator", "nodeId": str(node_id)}
|
368
|
+
)
|
369
|
+
|
370
|
+
node_id += 1
|
371
|
+
data = {
|
372
|
+
"name": "Note",
|
373
|
+
"nodeId": str(node_id),
|
374
|
+
"text": f"field{field_id}
0.00 ha",
|
375
|
+
"color": "4278190080",
|
376
|
+
"fixedSize": "true",
|
377
|
+
}
|
378
|
+
note_node = self.create_element("Note", data)
|
379
|
+
name_indicator_node.append(note_node)
|
380
|
+
|
381
|
+
return name_indicator_node, node_id
|
382
|
+
|
383
|
+
def get_user_attribute_node(
|
384
|
+
self, node_id: int, attributes: list[tuple[str, str, str]]
|
385
|
+
) -> ET.Element:
|
386
|
+
"""Creates an XML user attribute node with given node ID.
|
387
|
+
|
388
|
+
Arguments:
|
389
|
+
node_id (int): The node ID of the user attribute node.
|
390
|
+
attributes (list[tuple[str, str, str]]): The list of attributes to add to the node.
|
391
|
+
|
392
|
+
Returns:
|
393
|
+
ET.Element: The created user attribute node.
|
394
|
+
"""
|
395
|
+
user_attribute_node = ET.Element("UserAttribute")
|
396
|
+
user_attribute_node.set("nodeId", str(node_id))
|
397
|
+
|
398
|
+
for name, attr_type, value in attributes:
|
399
|
+
data = {
|
400
|
+
"name": name,
|
401
|
+
"type": attr_type,
|
402
|
+
"value": value,
|
403
|
+
}
|
404
|
+
user_attribute_node.append(self.create_element("Attribute", data))
|
405
|
+
|
406
|
+
return user_attribute_node
|
407
|
+
|
408
|
+
def _read_tree_schema(self) -> list[dict[str, str]] | None:
|
409
|
+
"""Reads the tree schema from the game instance or from the custom schema.
|
410
|
+
|
411
|
+
Returns:
|
412
|
+
list[dict[str, int | str]] | None: The tree schema or None if the schema could not be
|
413
|
+
read.
|
414
|
+
"""
|
415
|
+
custom_schema = self.kwargs.get("tree_custom_schema")
|
416
|
+
if custom_schema:
|
417
|
+
tree_schema = custom_schema
|
418
|
+
else:
|
419
|
+
try:
|
420
|
+
tree_schema_path = self.game.tree_schema
|
421
|
+
except ValueError:
|
422
|
+
self.logger.warning("Tree schema path not set for the Game %s.", self.game.code)
|
423
|
+
return None
|
424
|
+
|
425
|
+
if not os.path.isfile(tree_schema_path):
|
426
|
+
self.logger.warning("Tree schema file was not found: %s.", tree_schema_path)
|
427
|
+
return None
|
428
|
+
|
429
|
+
try:
|
430
|
+
with open(tree_schema_path, "r", encoding="utf-8") as tree_schema_file:
|
431
|
+
tree_schema = json.load(tree_schema_file) # type: ignore
|
432
|
+
except json.JSONDecodeError as e:
|
433
|
+
self.logger.warning(
|
434
|
+
"Could not load tree schema from %s with error: %s", tree_schema_path, e
|
435
|
+
)
|
436
|
+
return None
|
437
|
+
|
438
|
+
return tree_schema # type: ignore
|
439
|
+
|
440
|
+
def _add_forests(self) -> None:
|
441
|
+
"""Adds forests to the map I3D file."""
|
442
|
+
tree_schema = self._read_tree_schema()
|
443
|
+
if not tree_schema:
|
444
|
+
return
|
445
|
+
|
446
|
+
forest_layer = self.map.get_texture_layer(by_usage=Parameters.FOREST)
|
447
|
+
if not forest_layer:
|
448
|
+
self.logger.warning("Forest layer not found.")
|
449
|
+
return
|
450
|
+
|
451
|
+
weights_directory = self.game.weights_dir_path(self.map_directory)
|
452
|
+
forest_image_path = forest_layer.get_preview_or_path(weights_directory)
|
453
|
+
|
454
|
+
if not forest_image_path or not os.path.isfile(forest_image_path):
|
455
|
+
self.logger.warning("Forest image not found.")
|
456
|
+
return
|
457
|
+
|
458
|
+
tree = self.get_tree()
|
459
|
+
root = tree.getroot()
|
460
|
+
scene_node = root.find(".//Scene")
|
461
|
+
if scene_node is None:
|
462
|
+
self.logger.warning("Scene element not found in I3D file.")
|
463
|
+
return
|
464
|
+
|
465
|
+
node_id = TREE_NODE_ID_STARTING_VALUE
|
466
|
+
|
467
|
+
trees_node = self.create_element(
|
468
|
+
"TransformGroup",
|
469
|
+
{
|
470
|
+
"name": "trees",
|
471
|
+
"translation": f"0 {TREES_DEFAULT_Z_VALUE} 0",
|
472
|
+
"nodeId": str(node_id),
|
473
|
+
},
|
474
|
+
)
|
475
|
+
node_id += 1
|
476
|
+
|
477
|
+
forest_image = cv2.imread(forest_image_path, cv2.IMREAD_UNCHANGED)
|
478
|
+
for x, y in self.non_empty_pixels(forest_image, step=self.map.i3d_settings.forest_density):
|
479
|
+
xcs, ycs = self.top_left_coordinates_to_center((x, y))
|
480
|
+
node_id += 1
|
481
|
+
|
482
|
+
rotation = randint(-180, 180)
|
483
|
+
shifted_xcs, shifted_ycs = self.randomize_coordinates(
|
484
|
+
(xcs, ycs),
|
485
|
+
self.map.i3d_settings.forest_density,
|
486
|
+
self.map.i3d_settings.trees_relative_shift,
|
487
|
+
)
|
488
|
+
|
489
|
+
random_tree = choice(tree_schema)
|
490
|
+
tree_name = random_tree["name"]
|
491
|
+
tree_id = random_tree["reference_id"]
|
492
|
+
|
493
|
+
data = {
|
494
|
+
"name": tree_name,
|
495
|
+
"translation": f"{shifted_xcs} 0 {shifted_ycs}",
|
496
|
+
"rotation": f"0 {rotation} 0",
|
497
|
+
"referenceId": str(tree_id),
|
498
|
+
"nodeId": str(node_id),
|
499
|
+
}
|
500
|
+
trees_node.append(self.create_element("ReferenceNode", data))
|
501
|
+
|
502
|
+
scene_node.append(trees_node)
|
503
|
+
self.save_tree(tree)
|
504
|
+
|
505
|
+
@staticmethod
|
506
|
+
def randomize_coordinates(
|
507
|
+
coordinates: tuple[int, int], density: int, shift_percent: int
|
508
|
+
) -> tuple[float, float]:
|
509
|
+
"""Randomizes the coordinates of the point with the given density.
|
510
|
+
|
511
|
+
Arguments:
|
512
|
+
coordinates (tuple[int, int]): The coordinates of the point.
|
513
|
+
density (int): The density of the randomization.
|
514
|
+
shift_percent (int): Maximum relative shift in percent.
|
515
|
+
|
516
|
+
Returns:
|
517
|
+
tuple[float, float]: The randomized coordinates of the point.
|
518
|
+
"""
|
519
|
+
shift_range = density * shift_percent / 100
|
520
|
+
|
521
|
+
x_shift = uniform(-shift_range, shift_range)
|
522
|
+
y_shift = uniform(-shift_range, shift_range)
|
523
|
+
|
524
|
+
x, y = coordinates
|
525
|
+
|
526
|
+
return x + x_shift, y + y_shift
|
527
|
+
|
528
|
+
@staticmethod
|
529
|
+
def non_empty_pixels(
|
530
|
+
image: np.ndarray, step: int = 1
|
531
|
+
) -> Generator[tuple[int, int], None, None]:
|
532
|
+
"""Receives numpy array, which represents single-channeled image of uint8 type.
|
533
|
+
Yield coordinates of non-empty pixels (pixels with value greater than 0).
|
534
|
+
|
535
|
+
Arguments:
|
536
|
+
image (np.ndarray): The image to get non-empty pixels from.
|
537
|
+
step (int, optional): The step to iterate through the image. Defaults to 1.
|
538
|
+
|
539
|
+
Yields:
|
540
|
+
tuple[int, int]: The coordinates of non-empty pixels.
|
541
|
+
"""
|
542
|
+
for y, row in enumerate(image[::step]):
|
543
|
+
for x, value in enumerate(row[::step]):
|
544
|
+
if value > 0:
|
545
|
+
yield x * step, y * step
|
maps4fs/generator/dem.py
CHANGED
@@ -9,7 +9,7 @@ import numpy as np
|
|
9
9
|
# import rasterio # type: ignore
|
10
10
|
from pympler import asizeof # type: ignore
|
11
11
|
|
12
|
-
from maps4fs.generator.component import Component
|
12
|
+
from maps4fs.generator.component.base.component import Component
|
13
13
|
from maps4fs.generator.dtm.dtm import DTMProvider
|
14
14
|
|
15
15
|
|
@@ -132,7 +132,6 @@ class DEM(Component):
|
|
132
132
|
)
|
133
133
|
return data
|
134
134
|
|
135
|
-
# pylint: disable=no-member
|
136
135
|
def process(self) -> None:
|
137
136
|
"""Reads SRTM file, crops it to map size, normalizes and blurs it,
|
138
137
|
saves to map directory."""
|
@@ -275,14 +274,6 @@ class DEM(Component):
|
|
275
274
|
cv2.imwrite(self._dem_path, dem_data)
|
276
275
|
self.logger.warning("DEM data filled with zeros and saved to %s.", self._dem_path)
|
277
276
|
|
278
|
-
def previews(self) -> list:
|
279
|
-
"""This component does not have previews, returns empty list.
|
280
|
-
|
281
|
-
Returns:
|
282
|
-
list: Empty list.
|
283
|
-
"""
|
284
|
-
return []
|
285
|
-
|
286
277
|
def info_sequence(self) -> dict[Any, Any] | None: # type: ignore
|
287
278
|
"""Returns the information sequence for the component. Must be implemented in the child
|
288
279
|
class. If the component does not have an information sequence, an empty dictionary must be
|
maps4fs/generator/dtm/srtm.py
CHANGED
maps4fs/generator/game.py
CHANGED
@@ -7,9 +7,9 @@ from __future__ import annotations
|
|
7
7
|
import os
|
8
8
|
|
9
9
|
from maps4fs.generator.background import Background
|
10
|
-
from maps4fs.generator.config import Config
|
10
|
+
from maps4fs.generator.component.config import Config
|
11
|
+
from maps4fs.generator.component.i3d import I3d
|
11
12
|
from maps4fs.generator.grle import GRLE
|
12
|
-
from maps4fs.generator.i3d import I3d
|
13
13
|
from maps4fs.generator.satellite import Satellite
|
14
14
|
from maps4fs.generator.texture import Texture
|
15
15
|
|
@@ -38,6 +38,7 @@ class Game:
|
|
38
38
|
_texture_schema: str | None = None
|
39
39
|
_grle_schema: str | None = None
|
40
40
|
_tree_schema: str | None = None
|
41
|
+
_i3d_processing: bool = True
|
41
42
|
|
42
43
|
# Order matters! Some components depend on others.
|
43
44
|
components = [Texture, Background, GRLE, I3d, Config, Satellite]
|
@@ -156,6 +157,14 @@ class Game:
|
|
156
157
|
str: The path to the i3d file."""
|
157
158
|
raise NotImplementedError
|
158
159
|
|
160
|
+
@property
|
161
|
+
def i3d_processing(self) -> bool:
|
162
|
+
"""Returns whether the i3d file should be processed.
|
163
|
+
|
164
|
+
Returns:
|
165
|
+
bool: True if the i3d file should be processed, False otherwise."""
|
166
|
+
return self._i3d_processing
|
167
|
+
|
159
168
|
@property
|
160
169
|
def additional_dem_name(self) -> str | None:
|
161
170
|
"""Returns the name of the additional DEM file.
|
@@ -164,6 +173,17 @@ class Game:
|
|
164
173
|
str | None: The name of the additional DEM file."""
|
165
174
|
return self._additional_dem_name
|
166
175
|
|
176
|
+
def splines_file_path(self, map_directory: str) -> str:
|
177
|
+
"""Returns the path to the splines file.
|
178
|
+
|
179
|
+
Arguments:
|
180
|
+
map_directory (str): The path to the map directory.
|
181
|
+
|
182
|
+
Returns:
|
183
|
+
str: The path to the splines file."""
|
184
|
+
i3d_base_directory = os.path.dirname(self.i3d_file_path(map_directory))
|
185
|
+
return os.path.join(i3d_base_directory, "splines.i3d")
|
186
|
+
|
167
187
|
|
168
188
|
# pylint: disable=W0223
|
169
189
|
class FS22(Game):
|
@@ -172,6 +192,7 @@ class FS22(Game):
|
|
172
192
|
code = "FS22"
|
173
193
|
_map_template_path = os.path.join(working_directory, "data", "fs22-map-template.zip")
|
174
194
|
_texture_schema = os.path.join(working_directory, "data", "fs22-texture-schema.json")
|
195
|
+
_i3d_processing = False
|
175
196
|
|
176
197
|
def dem_file_path(self, map_directory: str) -> str:
|
177
198
|
"""Returns the path to the DEM file.
|
@@ -193,6 +214,16 @@ class FS22(Game):
|
|
193
214
|
str: The path to the weights directory."""
|
194
215
|
return os.path.join(map_directory, "maps", "map", "data")
|
195
216
|
|
217
|
+
def i3d_file_path(self, map_directory: str) -> str:
|
218
|
+
"""Returns the path to the i3d file.
|
219
|
+
|
220
|
+
Arguments:
|
221
|
+
map_directory (str): The path to the map directory.
|
222
|
+
|
223
|
+
Returns:
|
224
|
+
str: The path to the i3d file."""
|
225
|
+
return os.path.join(map_directory, "maps", "map", "map.i3d")
|
226
|
+
|
196
227
|
|
197
228
|
class FS25(Game):
|
198
229
|
"""Class used to define the game version FS25."""
|