maps4fs 2.9.3__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.

@@ -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 {}
@@ -542,6 +542,17 @@ class GRLE(ImageComponent, XMLComponent):
542
542
 
543
543
  for layer in texture_component.get_area_type_layers():
544
544
  pixel_value = area_type_to_pixel_value(layer.area_type) # type: ignore
545
+ # * Not enabled for now.
546
+ # * If the layer is invisible, we need to draw the mask from the info layer.
547
+ # if layer.invisible:
548
+ # self.logger.debug("Processing invisible area type layer: %s.", layer.name)
549
+ # if layer.info_layer:
550
+ # self.logger.debug("Info layer available: %s.", layer.info_layer)
551
+ # weight_image = self.draw_invisible_layer_mask(layer, environment_size)
552
+ # else:
553
+ # self.logger.debug("No info layer available for layer: %s.", layer.name)
554
+ # continue
555
+ # else:
545
556
  weight_image = self.get_resized_weight(layer, environment_size) # type: ignore
546
557
  if weight_image is None:
547
558
  self.logger.warning("Weight image for area type layer not found in %s.", layer.name)
@@ -562,6 +573,52 @@ class GRLE(ImageComponent, XMLComponent):
562
573
  self.logger.debug("Environment InfoLayer PNG file saved: %s.", info_layer_environment_path)
563
574
  self.preview_paths["environment"] = info_layer_environment_path
564
575
 
576
+ # def draw_invisible_layer_mask(self, layer: Layer, resize_to: int) -> np.ndarray:
577
+ # """Draw the mask for the invisible layer.
578
+
579
+ # Arguments:
580
+ # layer (Layer): The layer for which to draw the mask.
581
+ # resize_to (int): The size to which the mask should be resized.
582
+
583
+ # Returns:
584
+ # np.ndarray: The resized mask.
585
+ # """
586
+ # mask = np.zeros((self.map.size, self.map.size), dtype=np.uint8)
587
+ # polygons = self.get_infolayer_data(Parameters.TEXTURES, layer.info_layer)
588
+ # self.logger.debug("Found %d polygons in info layer %s.", len(polygons), layer.info_layer)
589
+
590
+ # for polygon in polygons:
591
+ # try:
592
+ # fitted_polygon = self.fit_object_into_bounds(
593
+ # polygon_points=polygon,
594
+ # # margin=self.map.grle_settings.farmland_margin,
595
+ # angle=self.rotation,
596
+ # )
597
+ # except ValueError as e:
598
+ # self.logger.debug(
599
+ # "Polygon could not be fitted into the map bounds with error: %s",
600
+ # e,
601
+ # )
602
+ # continue
603
+ # polygon_np = self.polygon_points_to_np(fitted_polygon)
604
+
605
+ # try:
606
+ # cv2.fillPoly(mask, [polygon_np], (float(255),)) # type: ignore
607
+ # except Exception as e:
608
+ # self.logger.debug(
609
+ # "Polygon could not be added to the mask with error: %s",
610
+ # e,
611
+ # )
612
+ # continue
613
+
614
+ # resized_mask = cv2.resize(
615
+ # mask,
616
+ # (resize_to, resize_to),
617
+ # interpolation=cv2.INTER_NEAREST,
618
+ # )
619
+
620
+ # return resized_mask
621
+
565
622
  @monitor_performance
566
623
  def get_resized_weight(
567
624
  self, layer: Layer, resize_to: int, dilations: int = 3
@@ -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 = 10000
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
- if not background_component.not_resized_path:
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(background_component.not_resized_path, cv2.IMREAD_UNCHANGED)
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
- output_image = cv2.bitwise_and(layer_image, mask) # type: ignore
473
- cumulative_image = cv2.bitwise_or(cumulative_image, output_image) # type: ignore
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)
@@ -40,7 +40,7 @@ logger.info(
40
40
  )
41
41
 
42
42
  TEMPLATES_STRUCTURE = {
43
- "fs25": ["texture_schemas", "tree_schemas", "map_templates"],
43
+ "fs25": ["texture_schemas", "tree_schemas", "buildings_schemas", "map_templates"],
44
44
  "fs22": ["texture_schemas", "map_templates"],
45
45
  }
46
46
 
@@ -82,15 +82,31 @@ def ensure_templates():
82
82
  text=True,
83
83
  )
84
84
 
85
- # Make the preparation script executable
86
- prep_script = os.path.join(clone_dir, "prepare_data.sh")
85
+ if os.name == "nt":
86
+ logger.info("Detected Windows OS, running PowerShell preparation script...")
87
+ prep_script = os.path.join(clone_dir, "prepare_data.ps1")
88
+ for_subprocess = [
89
+ "powershell",
90
+ "-ExecutionPolicy",
91
+ "Bypass",
92
+ "-File",
93
+ "prepare_data.ps1",
94
+ ]
95
+ else:
96
+ logger.info("Detected non-Windows OS, running bash preparation script...")
97
+ prep_script = os.path.join(clone_dir, "prepare_data.sh")
98
+ for_subprocess = ["./prepare_data.sh"]
99
+
87
100
  if os.path.exists(prep_script):
88
- os.chmod(prep_script, 0o755)
101
+ try:
102
+ os.chmod(prep_script, 0o755)
103
+ except Exception as e:
104
+ logger.warning("Could not set execute permissions on script: %s", str(e))
89
105
 
90
106
  logger.info("Running data preparation script...")
91
107
  # Run the preparation script from the cloned directory
92
108
  subprocess.run(
93
- ["./prepare_data.sh"], cwd=clone_dir, check=True, capture_output=True, text=True
109
+ for_subprocess, cwd=clone_dir, check=True, capture_output=True, text=True
94
110
  )
95
111
 
96
112
  # Copy the generated data directory to templates directory
maps4fs/generator/game.py CHANGED
@@ -5,9 +5,11 @@ template file and specific settings for map generation."""
5
5
  from __future__ import annotations
6
6
 
7
7
  import os
8
+ from typing import Callable
8
9
 
9
10
  import maps4fs.generator.config as mfscfg
10
11
  from maps4fs.generator.component.background import Background
12
+ from maps4fs.generator.component.building import Building
11
13
  from maps4fs.generator.component.config import Config
12
14
  from maps4fs.generator.component.grle import GRLE
13
15
  from maps4fs.generator.component.i3d import I3d
@@ -37,6 +39,7 @@ class Game:
37
39
  _texture_schema_file: str | None = None
38
40
  _grle_schema_file: str | None = None
39
41
  _tree_schema_file: str | None = None
42
+ _buildings_schema_file: str | None = None
40
43
  _i3d_processing: bool = True
41
44
  _plants_processing: bool = True
42
45
  _environment_processing: bool = True
@@ -45,7 +48,7 @@ class Game:
45
48
  _mesh_processing: bool = True
46
49
 
47
50
  # Order matters! Some components depend on others.
48
- components = [Satellite, Texture, Background, GRLE, I3d, Config]
51
+ components = [Satellite, Texture, Background, GRLE, I3d, Config, Building]
49
52
 
50
53
  def __init__(self, map_template_path: str | None = None):
51
54
  if map_template_path:
@@ -72,6 +75,11 @@ class Game:
72
75
  else:
73
76
  self._tree_schema = os.path.join(mfscfg.MFS_TEMPLATES_DIR, self._tree_schema_file) # type: ignore
74
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
+
75
83
  def set_components_by_names(self, component_names: list[str]) -> None:
76
84
  """Sets the components used for map generation by their names.
77
85
 
@@ -159,6 +167,19 @@ class Game:
159
167
  raise ValueError("Tree layers schema path not set.")
160
168
  return self._tree_schema
161
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
+
162
183
  def dem_file_path(self, map_directory: str) -> str:
163
184
  """Returns the path to the DEM file.
164
185
 
@@ -166,8 +187,7 @@ class Game:
166
187
  map_directory (str): The path to the map directory.
167
188
 
168
189
  Returns:
169
- str: The path to the DEM file.
170
- """
190
+ str: The path to the DEM file."""
171
191
  raise NotImplementedError
172
192
 
173
193
  def weights_dir_path(self, map_directory: str) -> str:
@@ -341,6 +361,45 @@ class Game:
341
361
  bool: True if the mesh should be processed, False otherwise."""
342
362
  return self._mesh_processing
343
363
 
364
+ def validate_template(self, map_directory: str) -> None:
365
+ """Validates that all required files exist in the map template directory.
366
+
367
+ Arguments:
368
+ map_directory (str): The path to the map directory.
369
+
370
+ Raises:
371
+ FileNotFoundError: If any required files are missing from the template.
372
+ """
373
+ all_files = []
374
+ for root, _, files in os.walk(map_directory):
375
+ for file in files:
376
+ all_files.append(os.path.join(root, file))
377
+
378
+ missing_files = []
379
+ for func in self.required_file_methods():
380
+ try:
381
+ required_filepath = func(map_directory)
382
+ except NotImplementedError:
383
+ continue
384
+ if required_filepath not in all_files:
385
+ missing_files.append(required_filepath)
386
+ if missing_files:
387
+ raise FileNotFoundError(f"The following files are not found: {missing_files}.")
388
+
389
+ def required_file_methods(self) -> list[Callable[[str], str]]:
390
+ """Returns a list of methods that return paths to required files for map generation.
391
+
392
+ Returns:
393
+ list[Callable[[str], str]]: List of methods that take a map directory path
394
+ and return file paths that are required for the map template.
395
+ """
396
+ return [
397
+ self.map_xml_path,
398
+ self.i3d_file_path,
399
+ self.get_environment_xml_path,
400
+ self.get_farmlands_xml_path,
401
+ ]
402
+
344
403
 
345
404
  class FS22(Game):
346
405
  """Class used to define the game version FS22."""
@@ -396,6 +455,7 @@ class FS25(Game):
396
455
  _texture_schema_file = "fs25-texture-schema.json"
397
456
  _grle_schema_file = "fs25-grle-schema.json"
398
457
  _tree_schema_file = "fs25-tree-schema.json"
458
+ _buildings_schema_file = "fs25-buildings-schema.json"
399
459
 
400
460
  def dem_file_path(self, map_directory: str) -> str:
401
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():
@@ -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
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=dHJLBt-Ua5rMMvUujAa3cuPSYvDA7tgfY7Z8tpvT_zo,7176
5
- maps4fs/generator/game.py,sha256=_LNiH__7FeSGsPKsuvAGiktt5GcJQVqcQYtsFZNWGyM,16106
6
- maps4fs/generator/map.py,sha256=ak74FNSDQXPqaB0gOaZqVo8Sy0fvf_3iXHg6FJkHPpA,15888
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=FOkJZrBifGLfEd33noL6AqfV_oW5cM-GQjXHNxzIKv4,5132
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
- maps4fs/generator/component/grle.py,sha256=uOd0dP-TeS85SZ87wafVj7AQv8L5VSdg2O0pDrtm_p0,27500
17
- maps4fs/generator/component/i3d.py,sha256=t6Y9JPXvKJ91wTBg_-noIsxDPk1OSdY9EwM5ZtgOl9Q,26771
18
- maps4fs/generator/component/layer.py,sha256=bdy1XGOODyPqYUM3b_wEY2H9Piz-AaHsCDecl-qRHiM,6627
17
+ maps4fs/generator/component/grle.py,sha256=FAcGmG7yq0icOElRoO4QMsVisZMsNrLhfNSWvGKnOHg,29899
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=FF2x6F2d5MRD3mls_m7z0JpnfmQVyoUkThJpXt1PMPM,38087
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.3.dist-info/licenses/LICENSE.md,sha256=Ptw8AkqJ60c4tRts6yuqGP_8B0dxwOGmJsp6YJ8dKqM,34328
27
- maps4fs-2.9.3.dist-info/METADATA,sha256=ytA78FbbbRnbDGRMyD6qz0K3I9egsVsRdMkp49qL0-w,10042
28
- maps4fs-2.9.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
29
- maps4fs-2.9.3.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
30
- maps4fs-2.9.3.dist-info/RECORD,,
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,,