maps4fs 2.9.31__py3-none-any.whl → 2.9.32__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.
Potentially problematic release.
This version of maps4fs might be problematic. Click here for more details.
- maps4fs/generator/component/building.py +570 -0
- maps4fs/generator/component/i3d.py +16 -5
- maps4fs/generator/component/layer.py +16 -0
- maps4fs/generator/component/texture.py +13 -2
- maps4fs/generator/config.py +1 -1
- maps4fs/generator/game.py +22 -2
- maps4fs/generator/map.py +2 -0
- maps4fs/generator/utils.py +15 -1
- {maps4fs-2.9.31.dist-info → maps4fs-2.9.32.dist-info}/METADATA +3 -1
- {maps4fs-2.9.31.dist-info → maps4fs-2.9.32.dist-info}/RECORD +13 -12
- {maps4fs-2.9.31.dist-info → maps4fs-2.9.32.dist-info}/WHEEL +0 -0
- {maps4fs-2.9.31.dist-info → maps4fs-2.9.32.dist-info}/licenses/LICENSE.md +0 -0
- {maps4fs-2.9.31.dist-info → maps4fs-2.9.32.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""Component for map buildings processing and generation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
from xml.etree import ElementTree as ET
|
|
7
|
+
|
|
8
|
+
import cv2
|
|
9
|
+
import numpy as np
|
|
10
|
+
from tqdm import tqdm
|
|
11
|
+
|
|
12
|
+
from maps4fs.generator.component.i3d import I3d
|
|
13
|
+
from maps4fs.generator.settings import Parameters
|
|
14
|
+
from maps4fs.generator.utils import get_region_by_coordinates
|
|
15
|
+
|
|
16
|
+
BUILDINGS_STARTING_NODE_ID = 10000
|
|
17
|
+
TOLERANCE_FACTOR = 0.3 # 30% size tolerance
|
|
18
|
+
DEFAULT_HEIGHT = 200
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
AREA_TYPES = {
|
|
22
|
+
"residential": 10,
|
|
23
|
+
"commercial": 20,
|
|
24
|
+
"industrial": 30,
|
|
25
|
+
"retail": 40,
|
|
26
|
+
"farmyard": 50,
|
|
27
|
+
"religious": 60,
|
|
28
|
+
"recreation": 70,
|
|
29
|
+
}
|
|
30
|
+
PIXEL_TYPES = {v: k for k, v in AREA_TYPES.items()}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BuildingEntry(NamedTuple):
|
|
34
|
+
"""Data structure for a building entry in the buildings schema."""
|
|
35
|
+
|
|
36
|
+
file: str
|
|
37
|
+
name: str
|
|
38
|
+
width: float
|
|
39
|
+
depth: float
|
|
40
|
+
categories: list[str]
|
|
41
|
+
regions: list[str]
|
|
42
|
+
type: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BuildingEntryCollection:
|
|
46
|
+
"""Collection of building entries with efficient lookup capabilities."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, building_entries: list[BuildingEntry], region: str):
|
|
49
|
+
"""Initialize the collection with a list of building entries for a specific region.
|
|
50
|
+
|
|
51
|
+
Arguments:
|
|
52
|
+
building_entries (list[BuildingEntry]): List of building entries to manage
|
|
53
|
+
region (str): The region for this collection (filters entries to this region only)
|
|
54
|
+
"""
|
|
55
|
+
self.region = region
|
|
56
|
+
# Filter entries to only include the specified region
|
|
57
|
+
self.entries = [entry for entry in building_entries if region in entry.regions]
|
|
58
|
+
# Create indices for faster lookup
|
|
59
|
+
self._create_indices()
|
|
60
|
+
|
|
61
|
+
def _create_indices(self) -> None:
|
|
62
|
+
"""Create indexed dictionaries for faster lookups."""
|
|
63
|
+
self.by_category: dict[str, list[BuildingEntry]] = {}
|
|
64
|
+
|
|
65
|
+
for entry in self.entries:
|
|
66
|
+
# Index by each category (all entries are already filtered by region)
|
|
67
|
+
for category in entry.categories:
|
|
68
|
+
if category not in self.by_category:
|
|
69
|
+
self.by_category[category] = []
|
|
70
|
+
self.by_category[category].append(entry)
|
|
71
|
+
|
|
72
|
+
def find_best_match(
|
|
73
|
+
self,
|
|
74
|
+
category: str,
|
|
75
|
+
width: float | None = None,
|
|
76
|
+
depth: float | None = None,
|
|
77
|
+
tolerance: float = 0.3,
|
|
78
|
+
) -> BuildingEntry | None:
|
|
79
|
+
"""Find the best matching building entry based on criteria.
|
|
80
|
+
All entries are already filtered by region during initialization.
|
|
81
|
+
|
|
82
|
+
Arguments:
|
|
83
|
+
category (str): Required building category
|
|
84
|
+
width (float | None): Desired width (optional)
|
|
85
|
+
depth (float | None): Desired depth (optional)
|
|
86
|
+
tolerance (float): Size tolerance factor (0.3 = 30% tolerance)
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
BuildingEntry | None: Best matching entry or None if no suitable match found
|
|
90
|
+
"""
|
|
91
|
+
# Start with buildings of the required category (already filtered by region)
|
|
92
|
+
candidates = self.by_category.get(category, [])
|
|
93
|
+
if not candidates:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
# Score each candidate
|
|
97
|
+
scored_candidates = []
|
|
98
|
+
for entry in candidates:
|
|
99
|
+
score = self._calculate_match_score(entry, category, width, depth, tolerance)
|
|
100
|
+
if score > 0: # Only consider viable matches
|
|
101
|
+
scored_candidates.append((score, entry))
|
|
102
|
+
|
|
103
|
+
if not scored_candidates:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# Return the highest scoring match
|
|
107
|
+
scored_candidates.sort(key=lambda x: x[0], reverse=True)
|
|
108
|
+
return scored_candidates[0][1]
|
|
109
|
+
|
|
110
|
+
def _calculate_match_score(
|
|
111
|
+
self,
|
|
112
|
+
entry: BuildingEntry,
|
|
113
|
+
category: str,
|
|
114
|
+
width: float | None,
|
|
115
|
+
depth: float | None,
|
|
116
|
+
tolerance: float,
|
|
117
|
+
) -> float:
|
|
118
|
+
"""Calculate a match score for a building entry.
|
|
119
|
+
Region is already matched during initialization.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
float: Match score (higher is better, 0 means no match)
|
|
123
|
+
"""
|
|
124
|
+
score = 0.0
|
|
125
|
+
|
|
126
|
+
# Category match (required) - base score
|
|
127
|
+
if category in entry.categories:
|
|
128
|
+
score = 100.0
|
|
129
|
+
else:
|
|
130
|
+
return 0.0 # Category mismatch = no match
|
|
131
|
+
|
|
132
|
+
# Size matching (if dimensions are provided)
|
|
133
|
+
if width is not None and depth is not None:
|
|
134
|
+
# Calculate how well the dimensions match (considering both orientations)
|
|
135
|
+
size_score1 = self._calculate_size_match(
|
|
136
|
+
entry.width, entry.depth, width, depth, tolerance
|
|
137
|
+
)
|
|
138
|
+
size_score2 = self._calculate_size_match(
|
|
139
|
+
entry.width, entry.depth, depth, width, tolerance
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Use the better orientation
|
|
143
|
+
size_score = max(size_score1, size_score2)
|
|
144
|
+
|
|
145
|
+
if size_score > 0:
|
|
146
|
+
score += (
|
|
147
|
+
size_score * 80.0
|
|
148
|
+
) # Size match contributes up to 80 points (increased since no region bonus)
|
|
149
|
+
else:
|
|
150
|
+
return 0.0 # Size too different = no match
|
|
151
|
+
|
|
152
|
+
return score
|
|
153
|
+
|
|
154
|
+
def _calculate_size_match(
|
|
155
|
+
self,
|
|
156
|
+
entry_width: float,
|
|
157
|
+
entry_depth: float,
|
|
158
|
+
target_width: float,
|
|
159
|
+
target_depth: float,
|
|
160
|
+
tolerance: float,
|
|
161
|
+
) -> float:
|
|
162
|
+
"""Calculate how well building dimensions match target dimensions.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
float: Size match score between 0 and 1 (1 = perfect match)
|
|
166
|
+
"""
|
|
167
|
+
width_ratio = min(entry_width, target_width) / max(entry_width, target_width)
|
|
168
|
+
depth_ratio = min(entry_depth, target_depth) / max(entry_depth, target_depth)
|
|
169
|
+
|
|
170
|
+
# Check if both dimensions are within tolerance
|
|
171
|
+
if width_ratio < (1 - tolerance) or depth_ratio < (1 - tolerance):
|
|
172
|
+
return 0.0
|
|
173
|
+
|
|
174
|
+
# Calculate combined size score (average of both dimension matches)
|
|
175
|
+
return (width_ratio + depth_ratio) / 2.0
|
|
176
|
+
|
|
177
|
+
def get_available_categories(self) -> list[str]:
|
|
178
|
+
"""Get list of available building categories for this region."""
|
|
179
|
+
return list(self.by_category.keys())
|
|
180
|
+
|
|
181
|
+
def filter_by_category(self, category: str) -> list[BuildingEntry]:
|
|
182
|
+
"""Get all buildings of a specific category (already filtered by region)."""
|
|
183
|
+
return self.by_category.get(category, [])
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def building_category_type_to_pixel(building_category: str) -> int | None:
|
|
187
|
+
"""Returns the pixel value representation of the building category.
|
|
188
|
+
If not found, returns None.
|
|
189
|
+
|
|
190
|
+
Arguments:
|
|
191
|
+
building_category (str | None): The building category type as a string.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
int | None: pixel value of the building category, or None if not found.
|
|
195
|
+
"""
|
|
196
|
+
return AREA_TYPES.get(building_category)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def pixel_value_to_building_category_type(pixel_value: int) -> str:
|
|
200
|
+
"""Returns the building category type representation of the pixel value.
|
|
201
|
+
If not found, returns "residential".
|
|
202
|
+
|
|
203
|
+
Arguments:
|
|
204
|
+
pixel_value (int | None): The pixel value to look up the building category for.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
str: building category of the pixel value, or "residential" if not found.
|
|
208
|
+
"""
|
|
209
|
+
return PIXEL_TYPES.get(pixel_value, "residential")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class Building(I3d):
|
|
213
|
+
"""Component for map buildings processing and generation.
|
|
214
|
+
|
|
215
|
+
Arguments:
|
|
216
|
+
game (Game): The game instance for which the map is generated.
|
|
217
|
+
coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
|
|
218
|
+
map_size (int): The size of the map in pixels.
|
|
219
|
+
map_rotated_size (int): The size of the map in pixels after rotation.
|
|
220
|
+
rotation (int): The rotation angle of the map.
|
|
221
|
+
map_directory (str): The directory where the map files are stored.
|
|
222
|
+
logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
|
|
223
|
+
info, warning. If not provided, default logging will be used.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
def preprocess(self) -> None:
|
|
227
|
+
"""Preprocess and prepare buildings schema and buildings map image."""
|
|
228
|
+
try:
|
|
229
|
+
buildings_schema_path = self.game.buildings_schema
|
|
230
|
+
except ValueError as e:
|
|
231
|
+
self.logger.warning("The game does not support buildings schema: %s", e)
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
custom_buildings_schema = self.map.buildings_custom_schema
|
|
235
|
+
if not custom_buildings_schema:
|
|
236
|
+
if not os.path.isfile(buildings_schema_path):
|
|
237
|
+
self.logger.warning(
|
|
238
|
+
"Buildings schema file not found at path: %s. Skipping buildings generation.",
|
|
239
|
+
buildings_schema_path,
|
|
240
|
+
)
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
with open(buildings_schema_path, "r", encoding="utf-8") as f:
|
|
245
|
+
buildings_schema = json.load(f)
|
|
246
|
+
|
|
247
|
+
self.buildings_schema = buildings_schema
|
|
248
|
+
|
|
249
|
+
except Exception as e:
|
|
250
|
+
self.logger.warning(
|
|
251
|
+
"Failed to load buildings schema from path: %s with error: %s. Skipping buildings generation.",
|
|
252
|
+
buildings_schema_path,
|
|
253
|
+
e,
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
self.buildings_schema = custom_buildings_schema
|
|
257
|
+
|
|
258
|
+
self.logger.info(
|
|
259
|
+
"Buildings schema loaded successfully with %d objects.", len(self.buildings_schema)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
self.xml_path = self.game.i3d_file_path(self.map_directory)
|
|
263
|
+
|
|
264
|
+
buildings_directory = os.path.join(self.map.map_directory, "buildings")
|
|
265
|
+
self.buildings_map_path = os.path.join(buildings_directory, "building_categories.png")
|
|
266
|
+
os.makedirs(buildings_directory, exist_ok=True)
|
|
267
|
+
|
|
268
|
+
texture_component = self.map.get_texture_component()
|
|
269
|
+
if not texture_component:
|
|
270
|
+
self.logger.warning("Texture component not found in the map.")
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
# Creating empty single-channel image for building categories.
|
|
274
|
+
buildings_map_image = np.zeros((self.map.size, self.map.size), dtype=np.uint8)
|
|
275
|
+
|
|
276
|
+
for layer in texture_component.get_building_category_layers():
|
|
277
|
+
self.logger.debug(
|
|
278
|
+
"Processing building category layer: %s (%s)",
|
|
279
|
+
layer.name,
|
|
280
|
+
layer.building_category,
|
|
281
|
+
)
|
|
282
|
+
pixel_value = building_category_type_to_pixel(layer.building_category) # type: ignore
|
|
283
|
+
if pixel_value is None:
|
|
284
|
+
self.logger.warning(
|
|
285
|
+
"Unknown building category '%s' for layer '%s'. Skipping.",
|
|
286
|
+
layer.building_category,
|
|
287
|
+
layer.name,
|
|
288
|
+
)
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
layer_path = layer.path(self.game.weights_dir_path(self.map.map_directory))
|
|
292
|
+
if not layer_path or not os.path.isfile(layer_path):
|
|
293
|
+
self.logger.warning("Layer texture file not found: %s. Skipping.", layer_path)
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
layer_image = cv2.imread(layer_path, cv2.IMREAD_UNCHANGED)
|
|
297
|
+
if layer_image is None:
|
|
298
|
+
self.logger.warning("Failed to read layer image: %s. Skipping.", layer_path)
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
mask = layer_image > 0
|
|
302
|
+
buildings_map_image[mask] = pixel_value
|
|
303
|
+
|
|
304
|
+
# Save the buildings map image
|
|
305
|
+
cv2.imwrite(self.buildings_map_path, buildings_map_image)
|
|
306
|
+
self.logger.info("Building categories map saved to: %s", self.buildings_map_path)
|
|
307
|
+
|
|
308
|
+
building_entries = []
|
|
309
|
+
for building_entry in self.buildings_schema:
|
|
310
|
+
building = BuildingEntry(**building_entry)
|
|
311
|
+
building_entries.append(building)
|
|
312
|
+
|
|
313
|
+
region = get_region_by_coordinates(self.coordinates)
|
|
314
|
+
|
|
315
|
+
self.buildings_collection = BuildingEntryCollection(building_entries, region)
|
|
316
|
+
self.logger.info(
|
|
317
|
+
"Buildings collection created with %d buildings for region '%s'.",
|
|
318
|
+
len(self.buildings_collection.entries),
|
|
319
|
+
region,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# pylint: disable=too-many-return-statements
|
|
323
|
+
def process(self) -> None:
|
|
324
|
+
"""Process and place buildings on the map based on the buildings map image and schema."""
|
|
325
|
+
if not hasattr(self, "buildings_map_path") or not os.path.isfile(self.buildings_map_path):
|
|
326
|
+
self.logger.warning(
|
|
327
|
+
"Buildings map path is not set or file does not exist. Skipping process step."
|
|
328
|
+
)
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
# Check if the collection contains any buildings.
|
|
332
|
+
if not self.buildings_collection.entries:
|
|
333
|
+
self.logger.warning(
|
|
334
|
+
"No buildings found in the collection. Buildings generation will be skipped.",
|
|
335
|
+
)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
buildings_map_image = cv2.imread(self.buildings_map_path, cv2.IMREAD_UNCHANGED)
|
|
339
|
+
if buildings_map_image is None:
|
|
340
|
+
self.logger.warning("Failed to read buildings map image. Skipping process step.")
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
self.logger.debug("Buildings map categories file found, processing...")
|
|
344
|
+
|
|
345
|
+
buildings = self.get_infolayer_data(Parameters.TEXTURES, Parameters.BUILDINGS)
|
|
346
|
+
if not buildings:
|
|
347
|
+
self.logger.warning("Buildings data not found in textures info layer.")
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
self.logger.info("Found %d building entries to process.", len(buildings))
|
|
351
|
+
|
|
352
|
+
# Initialize tracking for XML modifications
|
|
353
|
+
tree = self.get_tree()
|
|
354
|
+
root = tree.getroot()
|
|
355
|
+
|
|
356
|
+
if root is None:
|
|
357
|
+
self.logger.warning("Failed to get root element from I3D XML tree.")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
# Find the Scene element
|
|
361
|
+
scene_node = root.find(".//Scene")
|
|
362
|
+
if scene_node is None:
|
|
363
|
+
self.logger.warning("Scene element not found in I3D file.")
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
# Find or create the Files section
|
|
367
|
+
files_section = root.find("Files")
|
|
368
|
+
if files_section is None:
|
|
369
|
+
files_section = ET.SubElement(root, "Files")
|
|
370
|
+
|
|
371
|
+
# Find or create the buildings transform group in the scene
|
|
372
|
+
buildings_group = self._find_or_create_buildings_group(scene_node)
|
|
373
|
+
|
|
374
|
+
# Track used building files to avoid duplicates (file_path -> file_id mapping)
|
|
375
|
+
used_building_files = {}
|
|
376
|
+
file_id_counter = BUILDINGS_STARTING_NODE_ID
|
|
377
|
+
node_id_counter = BUILDINGS_STARTING_NODE_ID + 1000
|
|
378
|
+
|
|
379
|
+
not_resized_dem = self.get_not_resized_dem(with_foundations=True)
|
|
380
|
+
if not_resized_dem is None:
|
|
381
|
+
self.logger.warning("Not resized DEM not found.")
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
for building in tqdm(buildings, desc="Placing buildings", unit="building"):
|
|
385
|
+
try:
|
|
386
|
+
fitted_building = self.fit_object_into_bounds(
|
|
387
|
+
polygon_points=building, angle=self.rotation
|
|
388
|
+
)
|
|
389
|
+
except ValueError as e:
|
|
390
|
+
self.logger.debug(
|
|
391
|
+
"Building could not be fitted into the map bounds with error: %s",
|
|
392
|
+
e,
|
|
393
|
+
)
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
# 1. Identify the center point of the building polygon.
|
|
397
|
+
center_point = np.mean(fitted_building, axis=0).astype(int)
|
|
398
|
+
x, y = center_point
|
|
399
|
+
self.logger.debug("Center point of building polygon: %s", center_point)
|
|
400
|
+
|
|
401
|
+
pixel_value = buildings_map_image[y, x]
|
|
402
|
+
self.logger.debug("Pixel value at center point: %s", pixel_value)
|
|
403
|
+
|
|
404
|
+
category = pixel_value_to_building_category_type(pixel_value)
|
|
405
|
+
self.logger.debug("Building category at center point: %s", category)
|
|
406
|
+
|
|
407
|
+
# 2. Obtain building dimensions and rotation using minimum area bounding rectangle
|
|
408
|
+
polygon_np = self.polygon_points_to_np(fitted_building)
|
|
409
|
+
width, depth, rotation_angle = self._get_polygon_dimensions_and_rotation(polygon_np)
|
|
410
|
+
self.logger.debug(
|
|
411
|
+
"Building dimensions: width=%d, depth=%d, rotation=%d°",
|
|
412
|
+
width,
|
|
413
|
+
depth,
|
|
414
|
+
rotation_angle,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# 3. Find the best matching building from the collection (region already filtered)
|
|
418
|
+
best_match = self.buildings_collection.find_best_match(
|
|
419
|
+
category=category,
|
|
420
|
+
width=width,
|
|
421
|
+
depth=depth,
|
|
422
|
+
tolerance=TOLERANCE_FACTOR,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
if best_match:
|
|
426
|
+
self.logger.debug(
|
|
427
|
+
f"Best building match: {best_match.name} ({best_match.width}x{best_match.depth})"
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# Get world coordinates
|
|
431
|
+
x_center, y_center = self.top_left_coordinates_to_center(center_point)
|
|
432
|
+
try:
|
|
433
|
+
z = self.get_z_coordinate_from_dem(not_resized_dem, x, y)
|
|
434
|
+
except Exception as e:
|
|
435
|
+
self.logger.warning(
|
|
436
|
+
"Failed to get Z coordinate from DEM at (%d, %d) with error: %s. Using default height %d.",
|
|
437
|
+
x,
|
|
438
|
+
y,
|
|
439
|
+
e,
|
|
440
|
+
DEFAULT_HEIGHT,
|
|
441
|
+
)
|
|
442
|
+
z = DEFAULT_HEIGHT
|
|
443
|
+
|
|
444
|
+
# * Disabled for now, maybe re-enable later.
|
|
445
|
+
# Calculate scale factors to match the polygon size
|
|
446
|
+
# scale_width = width / best_match.width
|
|
447
|
+
# scale_depth = depth / best_match.depth
|
|
448
|
+
|
|
449
|
+
self.logger.debug(
|
|
450
|
+
"World coordinates for building: x=%.3f, y=%.3f, z=%.3f",
|
|
451
|
+
x_center,
|
|
452
|
+
y_center,
|
|
453
|
+
z,
|
|
454
|
+
)
|
|
455
|
+
# self.logger.debug(
|
|
456
|
+
# "Scale factors: width=%.4f, depth=%.4f",
|
|
457
|
+
# scale_width,
|
|
458
|
+
# scale_depth,
|
|
459
|
+
# )
|
|
460
|
+
|
|
461
|
+
# Add building file to Files section if not already present
|
|
462
|
+
file_id = None
|
|
463
|
+
if best_match.file not in used_building_files:
|
|
464
|
+
file_id = file_id_counter
|
|
465
|
+
file_element = ET.SubElement(files_section, "File")
|
|
466
|
+
file_element.set("fileId", str(file_id))
|
|
467
|
+
file_element.set("filename", best_match.file)
|
|
468
|
+
used_building_files[best_match.file] = file_id
|
|
469
|
+
file_id_counter += 1
|
|
470
|
+
else:
|
|
471
|
+
file_id = used_building_files[best_match.file]
|
|
472
|
+
|
|
473
|
+
# Create building instance in the buildings group
|
|
474
|
+
building_node = ET.SubElement(buildings_group, "ReferenceNode")
|
|
475
|
+
building_node.set("name", f"{best_match.name}_{node_id_counter}")
|
|
476
|
+
building_node.set("translation", f"{x_center:.3f} {z:.3f} {y_center:.3f}")
|
|
477
|
+
building_node.set("rotation", f"0 {rotation_angle:.3f} 0")
|
|
478
|
+
# building_node.set(
|
|
479
|
+
# "scale", f"{scale_width:.4f} 1.0 {scale_depth:.4f}"
|
|
480
|
+
# )
|
|
481
|
+
building_node.set("referenceId", str(file_id))
|
|
482
|
+
building_node.set("nodeId", str(node_id_counter))
|
|
483
|
+
|
|
484
|
+
node_id_counter += 1
|
|
485
|
+
|
|
486
|
+
else:
|
|
487
|
+
self.logger.debug(
|
|
488
|
+
f"No suitable building found for category '{category}' with dimensions {width:.2f}x{depth:.2f}"
|
|
489
|
+
)
|
|
490
|
+
continue
|
|
491
|
+
|
|
492
|
+
added_buildings_count = node_id_counter - (BUILDINGS_STARTING_NODE_ID + 1000)
|
|
493
|
+
self.logger.info("Total buildings placed: %d of %d", added_buildings_count, len(buildings))
|
|
494
|
+
|
|
495
|
+
# Save the modified XML tree
|
|
496
|
+
self.save_tree(tree)
|
|
497
|
+
self.logger.info("Buildings placement completed and saved to map.i3d")
|
|
498
|
+
|
|
499
|
+
def _get_polygon_dimensions_and_rotation(
|
|
500
|
+
self, polygon_points: np.ndarray
|
|
501
|
+
) -> tuple[float, float, float]:
|
|
502
|
+
"""Calculate width, depth, and rotation angle of a polygon using minimum area bounding rectangle.
|
|
503
|
+
|
|
504
|
+
Arguments:
|
|
505
|
+
polygon_points (np.ndarray): Array of polygon points with shape (n, 2)
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
tuple[float, float, float]: width, depth, and rotation angle in degrees
|
|
509
|
+
"""
|
|
510
|
+
# Convert to the format expected by cv2.minAreaRect (needs to be float32)
|
|
511
|
+
points = polygon_points.astype(np.float32)
|
|
512
|
+
|
|
513
|
+
# Find the minimum area bounding rectangle
|
|
514
|
+
rect = cv2.minAreaRect(points)
|
|
515
|
+
|
|
516
|
+
# rect contains: ((center_x, center_y), (width, height), angle)
|
|
517
|
+
(_, _), (width, height), angle = rect
|
|
518
|
+
|
|
519
|
+
# OpenCV's minAreaRect returns angle in range [-90, 0) for the longer side
|
|
520
|
+
# We need to convert this to a proper world rotation angle
|
|
521
|
+
|
|
522
|
+
# First, ensure width is the longer dimension
|
|
523
|
+
if width < height:
|
|
524
|
+
# Swap dimensions
|
|
525
|
+
width, height = height, width
|
|
526
|
+
# When we swap dimensions, we need to adjust the angle by 90 degrees
|
|
527
|
+
angle = angle + 90.0
|
|
528
|
+
|
|
529
|
+
# Convert OpenCV angle to world rotation angle
|
|
530
|
+
# OpenCV angle is measured from the horizontal axis, counter-clockwise
|
|
531
|
+
# But we want the angle in degrees for Y-axis rotation in 3D space
|
|
532
|
+
rotation_angle = -angle # Negative because 3D rotation is clockwise positive
|
|
533
|
+
|
|
534
|
+
# Normalize to [0, 360) range
|
|
535
|
+
while rotation_angle < 0:
|
|
536
|
+
rotation_angle += 360
|
|
537
|
+
while rotation_angle >= 360:
|
|
538
|
+
rotation_angle -= 360
|
|
539
|
+
|
|
540
|
+
return width, height, rotation_angle
|
|
541
|
+
|
|
542
|
+
def _find_or_create_buildings_group(self, scene_node: ET.Element) -> ET.Element:
|
|
543
|
+
"""Find or create the buildings transform group in the scene.
|
|
544
|
+
|
|
545
|
+
Arguments:
|
|
546
|
+
scene_node (ET.Element): The scene element of the XML tree
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
ET.Element: The buildings transform group element
|
|
550
|
+
"""
|
|
551
|
+
# Look for existing buildings group in the scene
|
|
552
|
+
for transform_group in scene_node.iter("TransformGroup"):
|
|
553
|
+
if transform_group.get("name") == "buildings":
|
|
554
|
+
return transform_group
|
|
555
|
+
|
|
556
|
+
# Create new buildings group if not found using the proper element creation method
|
|
557
|
+
buildings_group = self.create_element(
|
|
558
|
+
"TransformGroup",
|
|
559
|
+
{
|
|
560
|
+
"name": "buildings",
|
|
561
|
+
"translation": "0 0 0",
|
|
562
|
+
"nodeId": str(BUILDINGS_STARTING_NODE_ID),
|
|
563
|
+
},
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
scene_node.append(buildings_group)
|
|
567
|
+
return buildings_group
|
|
568
|
+
|
|
569
|
+
def info_sequence(self) -> dict[str, dict[str, str | float | int]]:
|
|
570
|
+
return {}
|
|
@@ -12,13 +12,14 @@ import cv2
|
|
|
12
12
|
import numpy as np
|
|
13
13
|
from tqdm import tqdm
|
|
14
14
|
|
|
15
|
+
from maps4fs.generator.component.base.component_image import ImageComponent
|
|
15
16
|
from maps4fs.generator.component.base.component_xml import XMLComponent
|
|
16
17
|
from maps4fs.generator.monitor import monitor_performance
|
|
17
18
|
from maps4fs.generator.settings import Parameters
|
|
18
19
|
|
|
19
20
|
NODE_ID_STARTING_VALUE = 2000
|
|
20
21
|
SPLINES_NODE_ID_STARTING_VALUE = 5000
|
|
21
|
-
TREE_NODE_ID_STARTING_VALUE =
|
|
22
|
+
TREE_NODE_ID_STARTING_VALUE = 30000
|
|
22
23
|
|
|
23
24
|
FIELDS_ATTRIBUTES = [
|
|
24
25
|
("angle", "integer", "0"),
|
|
@@ -30,7 +31,7 @@ FIELDS_ATTRIBUTES = [
|
|
|
30
31
|
]
|
|
31
32
|
|
|
32
33
|
|
|
33
|
-
class I3d(XMLComponent):
|
|
34
|
+
class I3d(XMLComponent, ImageComponent):
|
|
34
35
|
"""Component for map i3d file settings and configuration.
|
|
35
36
|
|
|
36
37
|
Arguments:
|
|
@@ -690,9 +691,13 @@ class I3d(XMLComponent):
|
|
|
690
691
|
|
|
691
692
|
return recommended_step if not current_step else max(recommended_step, current_step)
|
|
692
693
|
|
|
693
|
-
def get_not_resized_dem(self) -> np.ndarray | None:
|
|
694
|
+
def get_not_resized_dem(self, with_foundations: bool = False) -> np.ndarray | None:
|
|
694
695
|
"""Reads the not resized DEM image from the background component.
|
|
695
696
|
|
|
697
|
+
Arguments:
|
|
698
|
+
with_foundations (bool, optional): Whether to get the DEM with foundations.
|
|
699
|
+
Defaults to False.
|
|
700
|
+
|
|
696
701
|
Returns:
|
|
697
702
|
np.ndarray | None: The not resized DEM image or None if the image could not be read.
|
|
698
703
|
"""
|
|
@@ -701,11 +706,17 @@ class I3d(XMLComponent):
|
|
|
701
706
|
self.logger.warning("Background component not found.")
|
|
702
707
|
return None
|
|
703
708
|
|
|
704
|
-
|
|
709
|
+
dem_path = (
|
|
710
|
+
background_component.not_resized_with_foundations_path
|
|
711
|
+
if with_foundations
|
|
712
|
+
else background_component.not_resized_path
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
if not dem_path:
|
|
705
716
|
self.logger.warning("Not resized DEM path not found.")
|
|
706
717
|
return None
|
|
707
718
|
|
|
708
|
-
not_resized_dem = cv2.imread(
|
|
719
|
+
not_resized_dem = cv2.imread(dem_path, cv2.IMREAD_UNCHANGED)
|
|
709
720
|
|
|
710
721
|
return not_resized_dem
|
|
711
722
|
|
|
@@ -21,6 +21,16 @@ class Layer:
|
|
|
21
21
|
usage (str | None): Usage of the layer.
|
|
22
22
|
background (bool): Flag to determine if the layer is a background.
|
|
23
23
|
invisible (bool): Flag to determine if the layer is invisible.
|
|
24
|
+
procedural (list[str] | None): List of procedural textures to apply.
|
|
25
|
+
border (int | None): Border size in pixels.
|
|
26
|
+
precise_tags (dict[str, str | list[str]] | None): Dictionary of precise tags to search for.
|
|
27
|
+
precise_usage (str | None): Precise usage of the layer.
|
|
28
|
+
area_type (str | None): Type of the area (e.g., residential, commercial).
|
|
29
|
+
area_water (bool): Flag to determine if the area is water.
|
|
30
|
+
indoor (bool): Flag to determine if the layer is indoor.
|
|
31
|
+
merge_into (str | None): Name of the layer to merge into.
|
|
32
|
+
building_category (str | None): Category of the building.
|
|
33
|
+
external (bool): External layers not being used by the game directly.
|
|
24
34
|
|
|
25
35
|
Attributes:
|
|
26
36
|
name (str): Name of the layer.
|
|
@@ -50,6 +60,8 @@ class Layer:
|
|
|
50
60
|
area_water: bool = False,
|
|
51
61
|
indoor: bool = False,
|
|
52
62
|
merge_into: str | None = None,
|
|
63
|
+
building_category: str | None = None,
|
|
64
|
+
external: bool = False,
|
|
53
65
|
):
|
|
54
66
|
self.name = name
|
|
55
67
|
self.count = count
|
|
@@ -70,6 +82,8 @@ class Layer:
|
|
|
70
82
|
self.area_water = area_water
|
|
71
83
|
self.indoor = indoor
|
|
72
84
|
self.merge_into = merge_into
|
|
85
|
+
self.building_category = building_category
|
|
86
|
+
self.external = external
|
|
73
87
|
|
|
74
88
|
def to_json(self) -> dict[str, str | list[str] | bool]: # type: ignore
|
|
75
89
|
"""Returns dictionary with layer data.
|
|
@@ -96,6 +110,8 @@ class Layer:
|
|
|
96
110
|
"area_water": self.area_water,
|
|
97
111
|
"indoor": self.indoor,
|
|
98
112
|
"merge_into": self.merge_into,
|
|
113
|
+
"building_category": self.building_category,
|
|
114
|
+
"external": self.external,
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
data = {k: v for k, v in data.items() if v is not None}
|
|
@@ -168,6 +168,14 @@ class Texture(ImageComponent):
|
|
|
168
168
|
"""
|
|
169
169
|
return [layer for layer in self.layers if layer.indoor]
|
|
170
170
|
|
|
171
|
+
def get_building_category_layers(self) -> list[Layer]:
|
|
172
|
+
"""Returns layers which have building category defined.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
list[Layer]: List of layers which have building category defined.
|
|
176
|
+
"""
|
|
177
|
+
return [layer for layer in self.layers if layer.building_category is not None]
|
|
178
|
+
|
|
171
179
|
def process(self) -> None:
|
|
172
180
|
"""Processes the data to generate textures."""
|
|
173
181
|
self._prepare_weights()
|
|
@@ -469,8 +477,11 @@ class Texture(ImageComponent):
|
|
|
469
477
|
self._draw_layer(layer, info_layer_data, layer_image) # type: ignore
|
|
470
478
|
self._add_roads(layer, info_layer_data)
|
|
471
479
|
|
|
472
|
-
|
|
473
|
-
|
|
480
|
+
if not layer.external:
|
|
481
|
+
output_image = cv2.bitwise_and(layer_image, mask) # type: ignore
|
|
482
|
+
cumulative_image = cv2.bitwise_or(cumulative_image, output_image) # type: ignore
|
|
483
|
+
else:
|
|
484
|
+
output_image = layer_image # type: ignore
|
|
474
485
|
|
|
475
486
|
cv2.imwrite(layer_path, output_image)
|
|
476
487
|
self.logger.debug("Texture %s saved.", layer_path)
|
maps4fs/generator/config.py
CHANGED
maps4fs/generator/game.py
CHANGED
|
@@ -5,11 +5,11 @@ template file and specific settings for map generation."""
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
7
|
import os
|
|
8
|
-
|
|
9
8
|
from typing import Callable
|
|
10
9
|
|
|
11
10
|
import maps4fs.generator.config as mfscfg
|
|
12
11
|
from maps4fs.generator.component.background import Background
|
|
12
|
+
from maps4fs.generator.component.building import Building
|
|
13
13
|
from maps4fs.generator.component.config import Config
|
|
14
14
|
from maps4fs.generator.component.grle import GRLE
|
|
15
15
|
from maps4fs.generator.component.i3d import I3d
|
|
@@ -39,6 +39,7 @@ class Game:
|
|
|
39
39
|
_texture_schema_file: str | None = None
|
|
40
40
|
_grle_schema_file: str | None = None
|
|
41
41
|
_tree_schema_file: str | None = None
|
|
42
|
+
_buildings_schema_file: str | None = None
|
|
42
43
|
_i3d_processing: bool = True
|
|
43
44
|
_plants_processing: bool = True
|
|
44
45
|
_environment_processing: bool = True
|
|
@@ -47,7 +48,7 @@ class Game:
|
|
|
47
48
|
_mesh_processing: bool = True
|
|
48
49
|
|
|
49
50
|
# Order matters! Some components depend on others.
|
|
50
|
-
components = [Satellite, Texture, Background, GRLE, I3d, Config]
|
|
51
|
+
components = [Satellite, Texture, Background, GRLE, I3d, Config, Building]
|
|
51
52
|
|
|
52
53
|
def __init__(self, map_template_path: str | None = None):
|
|
53
54
|
if map_template_path:
|
|
@@ -74,6 +75,11 @@ class Game:
|
|
|
74
75
|
else:
|
|
75
76
|
self._tree_schema = os.path.join(mfscfg.MFS_TEMPLATES_DIR, self._tree_schema_file) # type: ignore
|
|
76
77
|
|
|
78
|
+
if not self._buildings_schema_file:
|
|
79
|
+
self._buildings_schema_file = None
|
|
80
|
+
else:
|
|
81
|
+
self._buildings_schema_file = os.path.join(mfscfg.MFS_TEMPLATES_DIR, self._buildings_schema_file) # type: ignore
|
|
82
|
+
|
|
77
83
|
def set_components_by_names(self, component_names: list[str]) -> None:
|
|
78
84
|
"""Sets the components used for map generation by their names.
|
|
79
85
|
|
|
@@ -161,6 +167,19 @@ class Game:
|
|
|
161
167
|
raise ValueError("Tree layers schema path not set.")
|
|
162
168
|
return self._tree_schema
|
|
163
169
|
|
|
170
|
+
@property
|
|
171
|
+
def buildings_schema(self) -> str:
|
|
172
|
+
"""Returns the path to the buildings layers schema file.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValueError: If the buildings layers schema path is not set.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
str: The path to the buildings layers schema file."""
|
|
179
|
+
if not self._buildings_schema_file:
|
|
180
|
+
raise ValueError("Buildings layers schema path not set.")
|
|
181
|
+
return self._buildings_schema_file
|
|
182
|
+
|
|
164
183
|
def dem_file_path(self, map_directory: str) -> str:
|
|
165
184
|
"""Returns the path to the DEM file.
|
|
166
185
|
|
|
@@ -436,6 +455,7 @@ class FS25(Game):
|
|
|
436
455
|
_texture_schema_file = "fs25-texture-schema.json"
|
|
437
456
|
_grle_schema_file = "fs25-grle-schema.json"
|
|
438
457
|
_tree_schema_file = "fs25-tree-schema.json"
|
|
458
|
+
_buildings_schema_file = "fs25-buildings-schema.json"
|
|
439
459
|
|
|
440
460
|
def dem_file_path(self, map_directory: str) -> str:
|
|
441
461
|
"""Returns the path to the DEM file.
|
maps4fs/generator/map.py
CHANGED
|
@@ -123,11 +123,13 @@ class Map:
|
|
|
123
123
|
os.makedirs(self.map_directory, exist_ok=True)
|
|
124
124
|
self.texture_custom_schema = kwargs.get("texture_custom_schema", None)
|
|
125
125
|
self.tree_custom_schema = kwargs.get("tree_custom_schema", None)
|
|
126
|
+
self.buildings_custom_schema = kwargs.get("buildings_custom_schema", None)
|
|
126
127
|
|
|
127
128
|
json_data = {
|
|
128
129
|
"generation_settings.json": generation_settings_json,
|
|
129
130
|
"texture_custom_schema.json": self.texture_custom_schema,
|
|
130
131
|
"tree_custom_schema.json": self.tree_custom_schema,
|
|
132
|
+
"buildings_custom_schema.json": self.buildings_custom_schema,
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
for filename, data in json_data.items():
|
maps4fs/generator/utils.py
CHANGED
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import shutil
|
|
6
6
|
from datetime import datetime
|
|
7
|
-
from typing import Any
|
|
7
|
+
from typing import Any, Literal
|
|
8
8
|
from xml.etree import ElementTree as ET
|
|
9
9
|
|
|
10
10
|
import osmnx as ox
|
|
@@ -122,6 +122,20 @@ def get_country_by_coordinates(coordinates: tuple[float, float]) -> str:
|
|
|
122
122
|
return "Unknown"
|
|
123
123
|
|
|
124
124
|
|
|
125
|
+
def get_region_by_coordinates(coordinates: tuple[float, float]) -> Literal["EU", "US"]:
|
|
126
|
+
"""Get region (EU or US) by coordinates.
|
|
127
|
+
|
|
128
|
+
Arguments:
|
|
129
|
+
coordinates (tuple[float, float]): Latitude and longitude.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Literal["EU", "US"]: Region code.
|
|
133
|
+
"""
|
|
134
|
+
country = get_country_by_coordinates(coordinates)
|
|
135
|
+
# If country is not US, assume EU for simplicity.
|
|
136
|
+
return "US" if country == "United States" else "EU"
|
|
137
|
+
|
|
138
|
+
|
|
125
139
|
def get_timestamp() -> str:
|
|
126
140
|
"""Get current underscore-separated timestamp.
|
|
127
141
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maps4fs
|
|
3
|
-
Version: 2.9.
|
|
3
|
+
Version: 2.9.32
|
|
4
4
|
Summary: Generate map templates for Farming Simulator from real places.
|
|
5
5
|
Author-email: iwatkot <iwatkot@gmail.com>
|
|
6
6
|
License: GNU Affero General Public License v3.0
|
|
@@ -76,6 +76,7 @@ Dynamic: license-file
|
|
|
76
76
|
🚜 **Farming Simulator 22 & 25** - Generate maps for both game versions<br>
|
|
77
77
|
🗺️ **Flexible Map Sizes** - 2x2, 4x4, 8x8, 16x16 km + custom sizes<br>
|
|
78
78
|
✂️ **Map Scaling & Rotation** - Perfect positioning and sizing control<br>
|
|
79
|
+
🏘️ **Adding buildings** - Automatic building placement system<br>
|
|
79
80
|
|
|
80
81
|
🌍 **Real-World Foundation** - Built from OpenStreetMap and satellite data<br>
|
|
81
82
|
🏞️ **Accurate Terrain** - SRTM elevation data with custom DTM support<br>
|
|
@@ -86,6 +87,7 @@ Dynamic: license-file
|
|
|
86
87
|
🌲 **Natural Forests** - Tree placement with customizable density<br>
|
|
87
88
|
🌊 **Water Systems** - Rivers, lakes, and water planes<br>
|
|
88
89
|
🌿 **Decorative Foliage** - Realistic vegetation and grass areas<br>
|
|
90
|
+
🏘️ **Intelligent Building Placement** - Automatic building placement in appropriate areas<br>
|
|
89
91
|
|
|
90
92
|
🚧 **Complete Spline Networks** - Roads and infrastructure<br>
|
|
91
93
|
🔷 **Background Terrain** - 3D *.obj files for surrounding landscape<br>
|
|
@@ -1,30 +1,31 @@
|
|
|
1
1
|
maps4fs/__init__.py,sha256=5ixsCA5vgcIV0OrF9EJBm91Mmc_KfMiDRM-QyifMAvo,386
|
|
2
2
|
maps4fs/logger.py,sha256=aZAa9glzgvH6ySVDLelSPTwHfWZtpGK5YBl-ufNUsPg,801
|
|
3
3
|
maps4fs/generator/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
|
|
4
|
-
maps4fs/generator/config.py,sha256=
|
|
5
|
-
maps4fs/generator/game.py,sha256=
|
|
6
|
-
maps4fs/generator/map.py,sha256=
|
|
4
|
+
maps4fs/generator/config.py,sha256=_s5pLpATVF-hwpcVquDUOhochD1RghPJczwyKepXtrc,7889
|
|
5
|
+
maps4fs/generator/game.py,sha256=bflRv0lxJ9-wkRvauh0k0RzIgF7zVWguygqQLcC7U-s,18457
|
|
6
|
+
maps4fs/generator/map.py,sha256=9F3PaoK63sbhNONvHN46k62-ZFMBQy5qvu9T193gwWs,16045
|
|
7
7
|
maps4fs/generator/monitor.py,sha256=Yrc7rClpmJK53SRzrOYZNBlwJmb5l6TkW-laFbyBEno,3524
|
|
8
8
|
maps4fs/generator/qgis.py,sha256=Es8hLuqN_KH8lDfnJE6He2rWYbAKJ3RGPn-o87S6CPI,6116
|
|
9
9
|
maps4fs/generator/settings.py,sha256=_QJL4ikQYLFOIB1zWqXjYvyLfoh3cr2RYb2IzsunMJg,13405
|
|
10
10
|
maps4fs/generator/statistics.py,sha256=ol0MTiehcCbQFfyYA7cKU-M4_cjiLCktnGbid4GYABU,2641
|
|
11
|
-
maps4fs/generator/utils.py,sha256=
|
|
11
|
+
maps4fs/generator/utils.py,sha256=qaHmS5I30OhDwd213bbctlplQQlX-qkHugyszXGmh0U,5587
|
|
12
12
|
maps4fs/generator/component/__init__.py,sha256=s01yVVVi8R2xxNvflu2D6wTd9I_g73AMM2x7vAC7GX4,490
|
|
13
13
|
maps4fs/generator/component/background.py,sha256=c2bjK3DkQXvA6Gtb_hUM9m-7fIVgp2BxJp09c4ZY3_A,49434
|
|
14
|
+
maps4fs/generator/component/building.py,sha256=Ru3AAFMPN2X_ePwM3Yoe-KDkuJySRERpvvvSxU1lJvc,22233
|
|
14
15
|
maps4fs/generator/component/config.py,sha256=tI2RQaGIqBgJIi9KjYfMZZ8AWg_YVUm6KKsBHGV241g,31285
|
|
15
16
|
maps4fs/generator/component/dem.py,sha256=vMVJtU2jAS-2lfB9JsqodZsrUvY1h5xr3Dh5qk6txwk,11895
|
|
16
17
|
maps4fs/generator/component/grle.py,sha256=FAcGmG7yq0icOElRoO4QMsVisZMsNrLhfNSWvGKnOHg,29899
|
|
17
|
-
maps4fs/generator/component/i3d.py,sha256=
|
|
18
|
-
maps4fs/generator/component/layer.py,sha256
|
|
18
|
+
maps4fs/generator/component/i3d.py,sha256=qB18jQWfWjlTiaZ4fHd16vv359hN2YHRLzGTZuJnbbU,27166
|
|
19
|
+
maps4fs/generator/component/layer.py,sha256=-MHnIXyJ7Xth9wOcjJCX-XkXBIYYv23lRRGbQ0XlHdU,7602
|
|
19
20
|
maps4fs/generator/component/satellite.py,sha256=1bPqd8JqAPqU0tEI9m-iuljMW9hXqlaCIxvq7kdpMY0,5219
|
|
20
|
-
maps4fs/generator/component/texture.py,sha256=
|
|
21
|
+
maps4fs/generator/component/texture.py,sha256=pmX8KE96dJAwzhnOe_ed-1In6sOLXs8-qdJ0aPc-ePM,38526
|
|
21
22
|
maps4fs/generator/component/base/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
|
|
22
23
|
maps4fs/generator/component/base/component.py,sha256=-7H3donrH19f0_rivNyI3fgLsiZkntXfGywEx4tOnM4,23924
|
|
23
24
|
maps4fs/generator/component/base/component_image.py,sha256=GXFkEFARNRkWkDiGSjvU4WX6f_8s6R1t2ZYqZflv1jk,9626
|
|
24
25
|
maps4fs/generator/component/base/component_mesh.py,sha256=2wGe_-wAZVRljMKzzVJ8jdzIETWg7LjxGj8A3inH5eI,25550
|
|
25
26
|
maps4fs/generator/component/base/component_xml.py,sha256=MT-VhU2dEckLFxAgmxg6V3gnv11di_94Qq6atfpOLdc,5342
|
|
26
|
-
maps4fs-2.9.
|
|
27
|
-
maps4fs-2.9.
|
|
28
|
-
maps4fs-2.9.
|
|
29
|
-
maps4fs-2.9.
|
|
30
|
-
maps4fs-2.9.
|
|
27
|
+
maps4fs-2.9.32.dist-info/licenses/LICENSE.md,sha256=Ptw8AkqJ60c4tRts6yuqGP_8B0dxwOGmJsp6YJ8dKqM,34328
|
|
28
|
+
maps4fs-2.9.32.dist-info/METADATA,sha256=4X4Nut1e-f0FRrwMLVvJy1LZwUxhs4JH6C1KAleQ4Yk,10213
|
|
29
|
+
maps4fs-2.9.32.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
30
|
+
maps4fs-2.9.32.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
|
|
31
|
+
maps4fs-2.9.32.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|