maps4fs 1.8.11__py3-none-any.whl → 1.8.13__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 +1 -0
- 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/canada.py +37 -0
- 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.11.dist-info → maps4fs-1.8.13.dist-info}/METADATA +3 -1
- maps4fs-1.8.13.dist-info/RECORD +40 -0
- maps4fs/generator/i3d.py +0 -624
- maps4fs-1.8.11.dist-info/RECORD +0 -36
- {maps4fs-1.8.11.dist-info → maps4fs-1.8.13.dist-info}/LICENSE.md +0 -0
- {maps4fs-1.8.11.dist-info → maps4fs-1.8.13.dist-info}/WHEEL +0 -0
- {maps4fs-1.8.11.dist-info → maps4fs-1.8.13.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
|
@@ -0,0 +1,37 @@
|
|
1
|
+
"""This module contains provider of Canada data."""
|
2
|
+
|
3
|
+
from maps4fs.generator.dtm.base.wcs import WCSProvider
|
4
|
+
from maps4fs.generator.dtm.dtm import DTMProvider
|
5
|
+
|
6
|
+
|
7
|
+
class CanadaProvider(WCSProvider, DTMProvider):
|
8
|
+
"""Provider of Canada data."""
|
9
|
+
|
10
|
+
_code = "canada"
|
11
|
+
_name = "Canada HRDEM"
|
12
|
+
_region = "CN"
|
13
|
+
_icon = "🇨🇦"
|
14
|
+
_resolution = 1
|
15
|
+
_author = "[kbrandwijk](https://github.com/kbrandwijk)"
|
16
|
+
_is_community = True
|
17
|
+
_is_base = False
|
18
|
+
_extents = (76.49491845750764, 33.66564101989275, -26.69697497450798, -157.7322455868316)
|
19
|
+
_instructions = (
|
20
|
+
"HRDEM coverage for Canada is limited. Make sure to check the "
|
21
|
+
"[coverage map](https://geo.ca/imagery/high-resolution-digital"
|
22
|
+
"-elevation-model-hrdem-canelevation-series/)."
|
23
|
+
)
|
24
|
+
|
25
|
+
_url = "https://datacube.services.geo.ca/ows/elevation"
|
26
|
+
_wcs_version = "1.1.1"
|
27
|
+
_source_crs = "EPSG:3979"
|
28
|
+
_tile_size = 1000
|
29
|
+
|
30
|
+
def get_wcs_parameters(self, tile: tuple[float, float, float, float]) -> dict:
|
31
|
+
return {
|
32
|
+
"identifier": "dtm",
|
33
|
+
"gridbasecrs": "urn:ogc:def:crs:EPSG::3979",
|
34
|
+
"boundingbox": f"{tile[1]},{tile[0]},{tile[3]},{tile[2]},urn:ogc:def:crs:EPSG::3979",
|
35
|
+
"format": "image/geotiff",
|
36
|
+
"timeout": 600,
|
37
|
+
}
|
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."""
|