maps4fs 1.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- maps4fs/__init__.py +22 -0
- maps4fs/generator/__init__.py +1 -0
- maps4fs/generator/background.py +625 -0
- maps4fs/generator/component.py +553 -0
- maps4fs/generator/config.py +109 -0
- maps4fs/generator/dem.py +297 -0
- maps4fs/generator/dtm/__init__.py +0 -0
- maps4fs/generator/dtm/base/wcs.py +71 -0
- maps4fs/generator/dtm/base/wms.py +70 -0
- maps4fs/generator/dtm/bavaria.py +113 -0
- maps4fs/generator/dtm/dtm.py +637 -0
- maps4fs/generator/dtm/england.py +31 -0
- maps4fs/generator/dtm/hessen.py +31 -0
- maps4fs/generator/dtm/niedersachsen.py +39 -0
- maps4fs/generator/dtm/nrw.py +30 -0
- maps4fs/generator/dtm/srtm.py +127 -0
- maps4fs/generator/dtm/usgs.py +87 -0
- maps4fs/generator/dtm/utils.py +61 -0
- maps4fs/generator/game.py +247 -0
- maps4fs/generator/grle.py +470 -0
- maps4fs/generator/i3d.py +624 -0
- maps4fs/generator/map.py +275 -0
- maps4fs/generator/qgis.py +196 -0
- maps4fs/generator/satellite.py +92 -0
- maps4fs/generator/settings.py +187 -0
- maps4fs/generator/texture.py +893 -0
- maps4fs/logger.py +46 -0
- maps4fs/toolbox/__init__.py +1 -0
- maps4fs/toolbox/background.py +63 -0
- maps4fs/toolbox/custom_osm.py +67 -0
- maps4fs/toolbox/dem.py +112 -0
- maps4fs-1.8.0.dist-info/LICENSE.md +190 -0
- maps4fs-1.8.0.dist-info/METADATA +693 -0
- maps4fs-1.8.0.dist-info/RECORD +36 -0
- maps4fs-1.8.0.dist-info/WHEEL +5 -0
- maps4fs-1.8.0.dist-info/top_level.txt +1 -0
maps4fs/generator/i3d.py
ADDED
@@ -0,0 +1,624 @@
|
|
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 import Component
|
16
|
+
from maps4fs.generator.texture import Texture
|
17
|
+
|
18
|
+
DISPLACEMENT_LAYER_SIZE_FOR_BIG_MAPS = 32768
|
19
|
+
NODE_ID_STARTING_VALUE = 2000
|
20
|
+
SPLINES_NODE_ID_STARTING_VALUE = 5000
|
21
|
+
TREE_NODE_ID_STARTING_VALUE = 10000
|
22
|
+
|
23
|
+
|
24
|
+
# pylint: disable=R0903
|
25
|
+
class I3d(Component):
|
26
|
+
"""Component for map i3d file settings and configuration.
|
27
|
+
|
28
|
+
Arguments:
|
29
|
+
game (Game): The game instance for which the map is generated.
|
30
|
+
coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
|
31
|
+
map_size (int): The size of the map in pixels.
|
32
|
+
map_rotated_size (int): The size of the map in pixels after rotation.
|
33
|
+
rotation (int): The rotation angle of the map.
|
34
|
+
map_directory (str): The directory where the map files are stored.
|
35
|
+
logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
|
36
|
+
info, warning. If not provided, default logging will be used.
|
37
|
+
"""
|
38
|
+
|
39
|
+
_map_i3d_path: str | None = None
|
40
|
+
|
41
|
+
def preprocess(self) -> None:
|
42
|
+
"""Gets the path to the map I3D file from the game instance and saves it to the instance
|
43
|
+
attribute. If the game does not support I3D files, the attribute is set to None."""
|
44
|
+
try:
|
45
|
+
self._map_i3d_path = self.game.i3d_file_path(self.map_directory)
|
46
|
+
self.logger.debug("Map I3D path: %s.", self._map_i3d_path)
|
47
|
+
except NotImplementedError:
|
48
|
+
self.logger.warning("I3D file processing is not implemented for this game.")
|
49
|
+
self._map_i3d_path = None
|
50
|
+
|
51
|
+
def process(self) -> None:
|
52
|
+
"""Updates the map I3D file with the default settings."""
|
53
|
+
self._update_i3d_file()
|
54
|
+
self._add_fields()
|
55
|
+
if self.game.code == "FS25":
|
56
|
+
self._add_forests()
|
57
|
+
self._add_splines()
|
58
|
+
|
59
|
+
def _get_tree(self) -> ET.ElementTree | None:
|
60
|
+
"""Returns the ElementTree instance of the map I3D file."""
|
61
|
+
if not self._map_i3d_path:
|
62
|
+
self.logger.debug("I3D is not obtained, skipping the update.")
|
63
|
+
return None
|
64
|
+
if not os.path.isfile(self._map_i3d_path):
|
65
|
+
self.logger.warning("I3D file not found: %s.", self._map_i3d_path)
|
66
|
+
return None
|
67
|
+
|
68
|
+
return ET.parse(self._map_i3d_path)
|
69
|
+
|
70
|
+
def _update_i3d_file(self) -> None:
|
71
|
+
"""Updates the map I3D file with the default settings."""
|
72
|
+
|
73
|
+
tree = self._get_tree()
|
74
|
+
if tree is None:
|
75
|
+
return
|
76
|
+
|
77
|
+
self.logger.debug("Map I3D file loaded from: %s.", self._map_i3d_path)
|
78
|
+
|
79
|
+
root = tree.getroot()
|
80
|
+
for map_elem in root.iter("Scene"):
|
81
|
+
for terrain_elem in map_elem.iter("TerrainTransformGroup"):
|
82
|
+
if self.map.shared_settings.change_height_scale:
|
83
|
+
suggested_height_scale = self.map.shared_settings.height_scale_value
|
84
|
+
if suggested_height_scale is not None and suggested_height_scale > 255:
|
85
|
+
new_height_scale = int(
|
86
|
+
self.map.shared_settings.height_scale_value # type: ignore
|
87
|
+
)
|
88
|
+
terrain_elem.set("heightScale", str(new_height_scale))
|
89
|
+
self.logger.info(
|
90
|
+
"heightScale attribute set to %s in TerrainTransformGroup element.",
|
91
|
+
new_height_scale,
|
92
|
+
)
|
93
|
+
|
94
|
+
self.logger.debug("TerrainTransformGroup element updated in I3D file.")
|
95
|
+
sun_elem = map_elem.find(".//Light[@name='sun']")
|
96
|
+
|
97
|
+
if sun_elem is not None:
|
98
|
+
self.logger.debug("Sun element found in I3D file.")
|
99
|
+
|
100
|
+
distance = self.map_size // 2
|
101
|
+
|
102
|
+
sun_elem.set("lastShadowMapSplitBboxMin", f"-{distance},-128,-{distance}")
|
103
|
+
sun_elem.set("lastShadowMapSplitBboxMax", f"{distance},148,{distance}")
|
104
|
+
|
105
|
+
self.logger.debug(
|
106
|
+
"Sun BBOX updated with half of the map size: %s.",
|
107
|
+
distance,
|
108
|
+
)
|
109
|
+
|
110
|
+
if self.map_size > 4096:
|
111
|
+
displacement_layer = terrain_elem.find(".//DisplacementLayer") # pylint: disable=W0631
|
112
|
+
|
113
|
+
if displacement_layer is not None:
|
114
|
+
displacement_layer.set("size", str(DISPLACEMENT_LAYER_SIZE_FOR_BIG_MAPS))
|
115
|
+
self.logger.debug(
|
116
|
+
"Map size is greater than 4096, DisplacementLayer size set to %s.",
|
117
|
+
)
|
118
|
+
|
119
|
+
tree.write(self._map_i3d_path) # type: ignore
|
120
|
+
self.logger.debug("Map I3D file saved to: %s.", self._map_i3d_path)
|
121
|
+
|
122
|
+
def previews(self) -> list[str]:
|
123
|
+
"""Returns a list of paths to the preview images (empty list).
|
124
|
+
The component does not generate any preview images so it returns an empty list.
|
125
|
+
|
126
|
+
Returns:
|
127
|
+
list[str]: An empty list.
|
128
|
+
"""
|
129
|
+
return []
|
130
|
+
|
131
|
+
# pylint: disable=R0914, R0915
|
132
|
+
def _add_splines(self) -> None:
|
133
|
+
"""Adds splines to the map I3D file."""
|
134
|
+
splines_i3d_path = os.path.join(self.map_directory, "map", "splines.i3d")
|
135
|
+
if not os.path.isfile(splines_i3d_path):
|
136
|
+
self.logger.warning("Splines I3D file not found: %s.", splines_i3d_path)
|
137
|
+
return
|
138
|
+
|
139
|
+
tree = ET.parse(splines_i3d_path)
|
140
|
+
|
141
|
+
textures_info_layer_path = self.get_infolayer_path("textures")
|
142
|
+
if not textures_info_layer_path:
|
143
|
+
return
|
144
|
+
|
145
|
+
with open(textures_info_layer_path, "r", encoding="utf-8") as textures_info_layer_file:
|
146
|
+
textures_info_layer = json.load(textures_info_layer_file)
|
147
|
+
|
148
|
+
roads_polylines: list[list[tuple[int, int]]] | None = textures_info_layer.get(
|
149
|
+
"roads_polylines"
|
150
|
+
)
|
151
|
+
|
152
|
+
if not roads_polylines:
|
153
|
+
self.logger.warning("Roads polylines data not found in textures info layer.")
|
154
|
+
return
|
155
|
+
|
156
|
+
self.logger.debug("Found %s roads polylines in textures info layer.", len(roads_polylines))
|
157
|
+
self.logger.debug("Starging to add roads polylines to the I3D file.")
|
158
|
+
|
159
|
+
root = tree.getroot()
|
160
|
+
# Find <Shapes> element in the I3D file.
|
161
|
+
shapes_node = root.find(".//Shapes")
|
162
|
+
# Find <Scene> element in the I3D file.
|
163
|
+
scene_node = root.find(".//Scene")
|
164
|
+
|
165
|
+
# Read the not resized DEM to obtain Z values for spline points.
|
166
|
+
background_component = self.map.get_component("Background")
|
167
|
+
if not background_component:
|
168
|
+
self.logger.warning("Background component not found.")
|
169
|
+
return
|
170
|
+
|
171
|
+
# pylint: disable=no-member
|
172
|
+
not_resized_dem = cv2.imread(
|
173
|
+
background_component.not_resized_path, cv2.IMREAD_UNCHANGED # type: ignore
|
174
|
+
)
|
175
|
+
self.logger.debug(
|
176
|
+
"Not resized DEM loaded from: %s. Shape: %s.",
|
177
|
+
background_component.not_resized_path, # type: ignore
|
178
|
+
not_resized_dem.shape,
|
179
|
+
)
|
180
|
+
dem_x_size, dem_y_size = not_resized_dem.shape
|
181
|
+
|
182
|
+
if shapes_node is not None and scene_node is not None:
|
183
|
+
node_id = SPLINES_NODE_ID_STARTING_VALUE
|
184
|
+
user_attributes_node = root.find(".//UserAttributes")
|
185
|
+
if user_attributes_node is None:
|
186
|
+
self.logger.warning("UserAttributes node not found in I3D file.")
|
187
|
+
return
|
188
|
+
|
189
|
+
for road_id, road in enumerate(roads_polylines, start=1):
|
190
|
+
# Add to scene node
|
191
|
+
# <Shape name="spline01_CSV" translation="0 0 0" nodeId="11" shapeId="11"/>
|
192
|
+
|
193
|
+
try:
|
194
|
+
fitted_road = self.fit_object_into_bounds(
|
195
|
+
linestring_points=road, angle=self.rotation
|
196
|
+
)
|
197
|
+
except ValueError as e:
|
198
|
+
self.logger.debug(
|
199
|
+
"Road %s could not be fitted into the map bounds with error: %s",
|
200
|
+
road_id,
|
201
|
+
e,
|
202
|
+
)
|
203
|
+
continue
|
204
|
+
|
205
|
+
self.logger.debug("Road %s has %s points.", road_id, len(fitted_road))
|
206
|
+
fitted_road = self.interpolate_points(
|
207
|
+
fitted_road, num_points=self.map.spline_settings.spline_density
|
208
|
+
)
|
209
|
+
self.logger.debug(
|
210
|
+
"Road %s has %s points after interpolation.", road_id, len(fitted_road)
|
211
|
+
)
|
212
|
+
|
213
|
+
spline_name = f"spline{road_id}"
|
214
|
+
|
215
|
+
shape_node = ET.Element("Shape")
|
216
|
+
shape_node.set("name", spline_name)
|
217
|
+
shape_node.set("translation", "0 0 0")
|
218
|
+
shape_node.set("nodeId", str(node_id))
|
219
|
+
shape_node.set("shapeId", str(node_id))
|
220
|
+
|
221
|
+
scene_node.append(shape_node)
|
222
|
+
|
223
|
+
road_ccs = [self.top_left_coordinates_to_center(point) for point in fitted_road]
|
224
|
+
|
225
|
+
# Add to shapes node
|
226
|
+
# <NurbsCurve name="spline01_CSV" shapeId="11" degree="3" form="open">
|
227
|
+
|
228
|
+
nurbs_curve_node = ET.Element("NurbsCurve")
|
229
|
+
nurbs_curve_node.set("name", spline_name)
|
230
|
+
nurbs_curve_node.set("shapeId", str(node_id))
|
231
|
+
nurbs_curve_node.set("degree", "3")
|
232
|
+
nurbs_curve_node.set("form", "open")
|
233
|
+
|
234
|
+
# Now for each point in the road add the following entry to nurbs_curve_node
|
235
|
+
# <cv c="-224.548401, 427.297546, -2047.570312" />
|
236
|
+
# The second coordinate (Z) will be 0 at the moment.
|
237
|
+
|
238
|
+
for point_ccs, point in zip(road_ccs, fitted_road):
|
239
|
+
cx, cy = point_ccs
|
240
|
+
x, y = point
|
241
|
+
|
242
|
+
x = int(x)
|
243
|
+
y = int(y)
|
244
|
+
|
245
|
+
x = max(0, min(x, dem_x_size - 1))
|
246
|
+
y = max(0, min(y, dem_y_size - 1))
|
247
|
+
|
248
|
+
z = not_resized_dem[y, x]
|
249
|
+
z *= self.get_z_scaling_factor() # type: ignore
|
250
|
+
|
251
|
+
cv_node = ET.Element("cv")
|
252
|
+
cv_node.set("c", f"{cx}, {z}, {cy}")
|
253
|
+
|
254
|
+
nurbs_curve_node.append(cv_node)
|
255
|
+
|
256
|
+
shapes_node.append(nurbs_curve_node)
|
257
|
+
|
258
|
+
# Add UserAttributes to the shape node.
|
259
|
+
# <UserAttribute nodeId="5000">
|
260
|
+
# <Attribute name="maxSpeedScale" type="integer" value="1"/>
|
261
|
+
# <Attribute name="speedLimit" type="integer" value="100"/>
|
262
|
+
# </UserAttribute>
|
263
|
+
|
264
|
+
user_attribute_node = ET.Element("UserAttribute")
|
265
|
+
user_attribute_node.set("nodeId", str(node_id))
|
266
|
+
|
267
|
+
attributes = [
|
268
|
+
("maxSpeedScale", "integer", "1"),
|
269
|
+
("speedLimit", "integer", "100"),
|
270
|
+
]
|
271
|
+
|
272
|
+
for name, attr_type, value in attributes:
|
273
|
+
user_attribute_node.append(I3d.create_attribute_node(name, attr_type, value))
|
274
|
+
|
275
|
+
user_attributes_node.append(user_attribute_node) # type: ignore
|
276
|
+
|
277
|
+
node_id += 1
|
278
|
+
|
279
|
+
tree.write(splines_i3d_path) # type: ignore
|
280
|
+
self.logger.debug("Splines I3D file saved to: %s.", splines_i3d_path)
|
281
|
+
|
282
|
+
# pylint: disable=R0914, R0915
|
283
|
+
def _add_fields(self) -> None:
|
284
|
+
"""Adds fields to the map I3D file."""
|
285
|
+
tree = self._get_tree()
|
286
|
+
if tree is None:
|
287
|
+
return
|
288
|
+
|
289
|
+
textures_info_layer_path = self.get_infolayer_path("textures")
|
290
|
+
if not textures_info_layer_path:
|
291
|
+
return
|
292
|
+
|
293
|
+
border = 0
|
294
|
+
textures_layer: Texture | None = self.map.get_component("Texture") # type: ignore
|
295
|
+
if textures_layer:
|
296
|
+
border = textures_layer.get_layer_by_usage("field").border # type: ignore
|
297
|
+
|
298
|
+
with open(textures_info_layer_path, "r", encoding="utf-8") as textures_info_layer_file:
|
299
|
+
textures_info_layer = json.load(textures_info_layer_file)
|
300
|
+
|
301
|
+
fields: list[list[tuple[int, int]]] | None = textures_info_layer.get("fields")
|
302
|
+
if not fields:
|
303
|
+
self.logger.warning("Fields data not found in textures info layer.")
|
304
|
+
return
|
305
|
+
|
306
|
+
self.logger.debug("Found %s fields in textures info layer.", len(fields))
|
307
|
+
self.logger.debug("Starging to add fields to the I3D file.")
|
308
|
+
|
309
|
+
root = tree.getroot()
|
310
|
+
gameplay_node = root.find(".//TransformGroup[@name='gameplay']")
|
311
|
+
if gameplay_node is not None:
|
312
|
+
fields_node = gameplay_node.find(".//TransformGroup[@name='fields']")
|
313
|
+
user_attributes_node = root.find(".//UserAttributes")
|
314
|
+
|
315
|
+
if fields_node is not None:
|
316
|
+
node_id = NODE_ID_STARTING_VALUE
|
317
|
+
|
318
|
+
# Not using enumerate because in case of the error, we do not increment
|
319
|
+
# the field_id. So as a result we do not have a gap in the field IDs.
|
320
|
+
field_id = 1
|
321
|
+
|
322
|
+
for field in tqdm(fields, desc="Adding fields", unit="field"):
|
323
|
+
try:
|
324
|
+
fitted_field = self.fit_object_into_bounds(
|
325
|
+
polygon_points=field, angle=self.rotation, border=border
|
326
|
+
)
|
327
|
+
except ValueError as e:
|
328
|
+
self.logger.debug(
|
329
|
+
"Field %s could not be fitted into the map bounds with error: %s",
|
330
|
+
field_id,
|
331
|
+
e,
|
332
|
+
)
|
333
|
+
continue
|
334
|
+
|
335
|
+
field_ccs = [
|
336
|
+
self.top_left_coordinates_to_center(point) for point in fitted_field
|
337
|
+
]
|
338
|
+
|
339
|
+
try:
|
340
|
+
cx, cy = self.get_polygon_center(field_ccs)
|
341
|
+
except Exception as e: # pylint: disable=W0718
|
342
|
+
self.logger.debug(
|
343
|
+
"Field %s could not be fitted into the map bounds.", field_id
|
344
|
+
)
|
345
|
+
self.logger.debug("Error: %s", e)
|
346
|
+
continue
|
347
|
+
|
348
|
+
# Creating the main field node.
|
349
|
+
field_node = ET.Element("TransformGroup")
|
350
|
+
field_node.set("name", f"field{field_id}")
|
351
|
+
field_node.set("translation", f"{cx} 0 {cy}")
|
352
|
+
field_node.set("nodeId", str(node_id))
|
353
|
+
|
354
|
+
# Adding UserAttributes to the field node.
|
355
|
+
user_attribute_node = self.create_user_attribute_node(node_id)
|
356
|
+
user_attributes_node.append(user_attribute_node) # type: ignore
|
357
|
+
|
358
|
+
node_id += 1
|
359
|
+
|
360
|
+
# Creating the polygon points node, which contains the points of the field.
|
361
|
+
polygon_points_node = ET.Element("TransformGroup")
|
362
|
+
polygon_points_node.set("name", "polygonPoints")
|
363
|
+
polygon_points_node.set("nodeId", str(node_id))
|
364
|
+
node_id += 1
|
365
|
+
|
366
|
+
for point_id, point in enumerate(field_ccs, start=1):
|
367
|
+
rx, ry = self.absolute_to_relative(point, (cx, cy))
|
368
|
+
|
369
|
+
node_id += 1
|
370
|
+
point_node = ET.Element("TransformGroup")
|
371
|
+
point_node.set("name", f"point{point_id}")
|
372
|
+
point_node.set("translation", f"{rx} 0 {ry}")
|
373
|
+
point_node.set("nodeId", str(node_id))
|
374
|
+
|
375
|
+
polygon_points_node.append(point_node)
|
376
|
+
|
377
|
+
field_node.append(polygon_points_node)
|
378
|
+
|
379
|
+
# Adding the name indicator node to the field node.
|
380
|
+
name_indicator_node, node_id = self.get_name_indicator_node(node_id, field_id)
|
381
|
+
field_node.append(name_indicator_node)
|
382
|
+
|
383
|
+
# Adding the teleport indicator node to the field node.
|
384
|
+
teleport_indicator_node, node_id = self.get_teleport_indicator_node(node_id)
|
385
|
+
field_node.append(teleport_indicator_node)
|
386
|
+
|
387
|
+
# Adding the field node to the fields node.
|
388
|
+
fields_node.append(field_node)
|
389
|
+
self.logger.debug("Field %s added to the I3D file.", field_id)
|
390
|
+
|
391
|
+
node_id += 1
|
392
|
+
field_id += 1
|
393
|
+
|
394
|
+
tree.write(self._map_i3d_path) # type: ignore
|
395
|
+
self.logger.debug("Map I3D file saved to: %s.", self._map_i3d_path)
|
396
|
+
|
397
|
+
def get_name_indicator_node(self, node_id: int, field_id: int) -> tuple[ET.Element, int]:
|
398
|
+
"""Creates a name indicator node with given node ID and field ID.
|
399
|
+
|
400
|
+
Arguments:
|
401
|
+
node_id (int): The node ID of the name indicator node.
|
402
|
+
field_id (int): The ID of the field.
|
403
|
+
|
404
|
+
Returns:
|
405
|
+
tuple[ET.Element, int]: The name indicator node and the updated node ID.
|
406
|
+
"""
|
407
|
+
node_id += 1
|
408
|
+
name_indicator_node = ET.Element("TransformGroup")
|
409
|
+
name_indicator_node.set("name", "nameIndicator")
|
410
|
+
name_indicator_node.set("nodeId", str(node_id))
|
411
|
+
|
412
|
+
node_id += 1
|
413
|
+
note_node = ET.Element("Note")
|
414
|
+
note_node.set("name", "Note")
|
415
|
+
note_node.set("nodeId", str(node_id))
|
416
|
+
note_node.set("text", f"field{field_id}
0.00 ha")
|
417
|
+
note_node.set("color", "4278190080")
|
418
|
+
note_node.set("fixedSize", "true")
|
419
|
+
|
420
|
+
name_indicator_node.append(note_node)
|
421
|
+
|
422
|
+
return name_indicator_node, node_id
|
423
|
+
|
424
|
+
def get_teleport_indicator_node(self, node_id: int) -> tuple[ET.Element, int]:
|
425
|
+
"""Creates a teleport indicator node with given node ID.
|
426
|
+
|
427
|
+
Arguments:
|
428
|
+
node_id (int): The node ID of the teleport indicator node.
|
429
|
+
|
430
|
+
Returns:
|
431
|
+
tuple[ET.Element, int]: The teleport indicator node and the updated node ID.
|
432
|
+
"""
|
433
|
+
node_id += 1
|
434
|
+
teleport_indicator_node = ET.Element("TransformGroup")
|
435
|
+
teleport_indicator_node.set("name", "teleportIndicator")
|
436
|
+
teleport_indicator_node.set("nodeId", str(node_id))
|
437
|
+
|
438
|
+
return teleport_indicator_node, node_id
|
439
|
+
|
440
|
+
@staticmethod
|
441
|
+
def create_user_attribute_node(node_id: int) -> ET.Element:
|
442
|
+
"""Creates an XML user attribute node with given node ID.
|
443
|
+
|
444
|
+
Arguments:
|
445
|
+
node_id (int): The node ID of the user attribute node.
|
446
|
+
|
447
|
+
Returns:
|
448
|
+
ET.Element: The created user attribute node.
|
449
|
+
"""
|
450
|
+
user_attribute_node = ET.Element("UserAttribute")
|
451
|
+
user_attribute_node.set("nodeId", str(node_id))
|
452
|
+
|
453
|
+
attributes = [
|
454
|
+
("angle", "integer", "0"),
|
455
|
+
("missionAllowed", "boolean", "true"),
|
456
|
+
("missionOnlyGrass", "boolean", "false"),
|
457
|
+
("nameIndicatorIndex", "string", "1"),
|
458
|
+
("polygonIndex", "string", "0"),
|
459
|
+
("teleportIndicatorIndex", "string", "2"),
|
460
|
+
]
|
461
|
+
|
462
|
+
for name, attr_type, value in attributes:
|
463
|
+
user_attribute_node.append(I3d.create_attribute_node(name, attr_type, value))
|
464
|
+
|
465
|
+
return user_attribute_node
|
466
|
+
|
467
|
+
@staticmethod
|
468
|
+
def create_attribute_node(name: str, attr_type: str, value: str) -> ET.Element:
|
469
|
+
"""Creates an XML attribute node with given name, type, and value.
|
470
|
+
|
471
|
+
Arguments:
|
472
|
+
name (str): The name of the attribute.
|
473
|
+
attr_type (str): The type of the attribute.
|
474
|
+
value (str): The value of the attribute.
|
475
|
+
|
476
|
+
Returns:
|
477
|
+
ET.Element: The created attribute node.
|
478
|
+
"""
|
479
|
+
attribute_node = ET.Element("Attribute")
|
480
|
+
attribute_node.set("name", name)
|
481
|
+
attribute_node.set("type", attr_type)
|
482
|
+
attribute_node.set("value", value)
|
483
|
+
return attribute_node
|
484
|
+
|
485
|
+
# pylint: disable=R0911
|
486
|
+
def _add_forests(self) -> None:
|
487
|
+
"""Adds forests to the map I3D file."""
|
488
|
+
custom_schema = self.kwargs.get("tree_custom_schema")
|
489
|
+
if custom_schema:
|
490
|
+
tree_schema = custom_schema
|
491
|
+
else:
|
492
|
+
try:
|
493
|
+
tree_schema_path = self.game.tree_schema
|
494
|
+
except ValueError:
|
495
|
+
self.logger.warning("Tree schema path not set for the Game %s.", self.game.code)
|
496
|
+
return
|
497
|
+
|
498
|
+
if not os.path.isfile(tree_schema_path):
|
499
|
+
self.logger.warning("Tree schema file was not found: %s.", tree_schema_path)
|
500
|
+
return
|
501
|
+
|
502
|
+
try:
|
503
|
+
with open(tree_schema_path, "r", encoding="utf-8") as tree_schema_file:
|
504
|
+
tree_schema = json.load(tree_schema_file) # type: ignore
|
505
|
+
except json.JSONDecodeError as e:
|
506
|
+
self.logger.warning(
|
507
|
+
"Could not load tree schema from %s with error: %s", tree_schema_path, e
|
508
|
+
)
|
509
|
+
return
|
510
|
+
|
511
|
+
texture_component: Texture | None = self.map.get_component("Texture") # type: ignore
|
512
|
+
if not texture_component:
|
513
|
+
self.logger.warning("Texture component not found.")
|
514
|
+
return
|
515
|
+
|
516
|
+
forest_layer = texture_component.get_layer_by_usage("forest")
|
517
|
+
|
518
|
+
if not forest_layer:
|
519
|
+
self.logger.warning("Forest layer not found.")
|
520
|
+
return
|
521
|
+
|
522
|
+
weights_directory = self.game.weights_dir_path(self.map_directory)
|
523
|
+
forest_image_path = forest_layer.get_preview_or_path(weights_directory)
|
524
|
+
|
525
|
+
if not forest_image_path or not os.path.isfile(forest_image_path):
|
526
|
+
self.logger.warning("Forest image not found.")
|
527
|
+
return
|
528
|
+
|
529
|
+
tree = self._get_tree()
|
530
|
+
if tree is None:
|
531
|
+
return
|
532
|
+
|
533
|
+
# Find the <Scene> element in the I3D file.
|
534
|
+
root = tree.getroot()
|
535
|
+
scene_node = root.find(".//Scene")
|
536
|
+
if scene_node is None:
|
537
|
+
self.logger.warning("Scene element not found in I3D file.")
|
538
|
+
return
|
539
|
+
|
540
|
+
self.logger.debug("Scene element found in I3D file, starting to add forests.")
|
541
|
+
|
542
|
+
node_id = TREE_NODE_ID_STARTING_VALUE
|
543
|
+
|
544
|
+
# Create <TransformGroup name="trees" translation="0 400 0" nodeId="{node_id}"> element.
|
545
|
+
trees_node = ET.Element("TransformGroup")
|
546
|
+
trees_node.set("name", "trees")
|
547
|
+
trees_node.set("translation", "0 400 0")
|
548
|
+
trees_node.set("nodeId", str(node_id))
|
549
|
+
node_id += 1
|
550
|
+
|
551
|
+
# pylint: disable=no-member
|
552
|
+
forest_image = cv2.imread(forest_image_path, cv2.IMREAD_UNCHANGED)
|
553
|
+
|
554
|
+
tree_count = 0
|
555
|
+
for x, y in self.non_empty_pixels(forest_image, step=self.map.i3d_settings.forest_density):
|
556
|
+
xcs, ycs = self.top_left_coordinates_to_center((x, y))
|
557
|
+
node_id += 1
|
558
|
+
|
559
|
+
rotation = randint(-180, 180)
|
560
|
+
xcs, ycs = self.randomize_coordinates( # type: ignore
|
561
|
+
(xcs, ycs), self.map.i3d_settings.forest_density
|
562
|
+
)
|
563
|
+
|
564
|
+
random_tree = choice(tree_schema) # type: ignore
|
565
|
+
tree_name = random_tree["name"]
|
566
|
+
tree_id = random_tree["reference_id"]
|
567
|
+
|
568
|
+
reference_node = ET.Element("ReferenceNode")
|
569
|
+
reference_node.set("name", tree_name) # type: ignore
|
570
|
+
reference_node.set("translation", f"{xcs} 0 {ycs}")
|
571
|
+
reference_node.set("rotation", f"0 {rotation} 0")
|
572
|
+
reference_node.set("referenceId", str(tree_id))
|
573
|
+
reference_node.set("nodeId", str(node_id))
|
574
|
+
|
575
|
+
trees_node.append(reference_node)
|
576
|
+
tree_count += 1
|
577
|
+
|
578
|
+
scene_node.append(trees_node)
|
579
|
+
self.logger.debug("Added %s trees to the I3D file.", tree_count)
|
580
|
+
|
581
|
+
tree.write(self._map_i3d_path) # type: ignore
|
582
|
+
self.logger.debug("Map I3D file saved to: %s.", self._map_i3d_path)
|
583
|
+
|
584
|
+
@staticmethod
|
585
|
+
def randomize_coordinates(coordinates: tuple[int, int], density: int) -> tuple[float, float]:
|
586
|
+
"""Randomizes the coordinates of the point with the given density.
|
587
|
+
|
588
|
+
Arguments:
|
589
|
+
coordinates (tuple[int, int]): The coordinates of the point.
|
590
|
+
density (int): The density of the randomization.
|
591
|
+
|
592
|
+
Returns:
|
593
|
+
tuple[float, float]: The randomized coordinates of the point.
|
594
|
+
"""
|
595
|
+
MAXIMUM_RELATIVE_SHIFT = 0.2 # pylint: disable=C0103
|
596
|
+
shift_range = density * MAXIMUM_RELATIVE_SHIFT
|
597
|
+
|
598
|
+
x_shift = uniform(-shift_range, shift_range)
|
599
|
+
y_shift = uniform(-shift_range, shift_range)
|
600
|
+
|
601
|
+
x, y = coordinates
|
602
|
+
x += x_shift # type: ignore
|
603
|
+
y += y_shift # type: ignore
|
604
|
+
|
605
|
+
return x, y
|
606
|
+
|
607
|
+
@staticmethod
|
608
|
+
def non_empty_pixels(
|
609
|
+
image: np.ndarray, step: int = 1
|
610
|
+
) -> Generator[tuple[int, int], None, None]:
|
611
|
+
"""Receives numpy array, which represents single-channeled image of uint8 type.
|
612
|
+
Yield coordinates of non-empty pixels (pixels with value greater than 0).
|
613
|
+
|
614
|
+
Arguments:
|
615
|
+
image (np.ndarray): The image to get non-empty pixels from.
|
616
|
+
step (int, optional): The step to iterate through the image. Defaults to 1.
|
617
|
+
|
618
|
+
Yields:
|
619
|
+
tuple[int, int]: The coordinates of non-empty pixels.
|
620
|
+
"""
|
621
|
+
for y, row in enumerate(image[::step]):
|
622
|
+
for x, value in enumerate(row[::step]):
|
623
|
+
if value > 0:
|
624
|
+
yield x * step, y * step
|