maps4fs 2.9.2__py3-none-any.whl → 2.9.37__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.
@@ -0,0 +1,706 @@
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
+ DEFAULT_HEIGHT = 200
18
+ AUTO_REGION = "auto"
19
+ ALL_REGIONS = "all"
20
+
21
+
22
+ AREA_TYPES = {
23
+ "residential": 10,
24
+ "commercial": 20,
25
+ "industrial": 30,
26
+ "retail": 40,
27
+ "farmyard": 50,
28
+ "religious": 60,
29
+ "recreation": 70,
30
+ }
31
+ PIXEL_TYPES = {v: k for k, v in AREA_TYPES.items()}
32
+
33
+
34
+ class BuildingEntry(NamedTuple):
35
+ """Data structure for a building entry in the buildings schema."""
36
+
37
+ file: str
38
+ name: str
39
+ width: float
40
+ depth: float
41
+ categories: list[str]
42
+ regions: list[str]
43
+ type: str | None = None
44
+
45
+
46
+ class BuildingEntryCollection:
47
+ """Collection of building entries with efficient lookup capabilities."""
48
+
49
+ def __init__(
50
+ self, building_entries: list[BuildingEntry], region: str, ignore_region: bool = False
51
+ ):
52
+ """Initialize the collection with a list of building entries for a specific region.
53
+
54
+ Arguments:
55
+ building_entries (list[BuildingEntry]): List of building entries to manage
56
+ region (str): The region for this collection (filters entries to this region only)
57
+ ignore_region (bool): If True, ignore region filtering and use all entries
58
+ """
59
+ self.region = region
60
+ self.ignore_region = ignore_region
61
+
62
+ # Filter entries based on ignore_region flag
63
+ if ignore_region:
64
+ self.entries = building_entries # Use all entries regardless of region
65
+ else:
66
+ self.entries = [entry for entry in building_entries if region in entry.regions]
67
+
68
+ # Create indices for faster lookup
69
+ self._create_indices()
70
+
71
+ def _create_indices(self) -> None:
72
+ """Create indexed dictionaries for faster lookups."""
73
+ self.by_category: dict[str, list[BuildingEntry]] = {}
74
+
75
+ for entry in self.entries:
76
+ # Index by each category (all entries are already filtered by region)
77
+ for category in entry.categories:
78
+ if category not in self.by_category:
79
+ self.by_category[category] = []
80
+ self.by_category[category].append(entry)
81
+
82
+ def find_best_match(
83
+ self,
84
+ category: str,
85
+ width: float | None = None,
86
+ depth: float | None = None,
87
+ tolerance: float = 0.3,
88
+ ) -> BuildingEntry | None:
89
+ """Find the best matching building entry based on criteria.
90
+ Entries are filtered by region during initialization unless ignore_region is True.
91
+
92
+ Arguments:
93
+ category (str): Required building category
94
+ width (float | None): Desired width (optional)
95
+ depth (float | None): Desired depth (optional)
96
+ tolerance (float): Size tolerance factor (0.3 = 30% tolerance)
97
+
98
+ Returns:
99
+ BuildingEntry | None: Best matching entry or None if no suitable match found
100
+ """
101
+ # Start with buildings of the required category (filtered by region unless ignore_region is True)
102
+ candidates = self.by_category.get(category, [])
103
+ if not candidates:
104
+ return None
105
+
106
+ # Score each candidate
107
+ scored_candidates = []
108
+ for entry in candidates:
109
+ score = self._calculate_match_score(entry, category, width, depth, tolerance)
110
+ if score > 0: # Only consider viable matches
111
+ scored_candidates.append((score, entry))
112
+
113
+ if not scored_candidates:
114
+ return None
115
+
116
+ # Return the highest scoring match
117
+ scored_candidates.sort(key=lambda x: x[0], reverse=True)
118
+ return scored_candidates[0][1]
119
+
120
+ def find_best_match_with_orientation(
121
+ self,
122
+ category: str,
123
+ width: float | None = None,
124
+ depth: float | None = None,
125
+ tolerance: float = 0.3,
126
+ ) -> tuple[BuildingEntry | None, bool]:
127
+ """Find the best matching building entry and determine if rotation is needed.
128
+
129
+ Arguments:
130
+ category (str): Required building category
131
+ width (float | None): Desired width (optional)
132
+ depth (float | None): Desired depth (optional)
133
+ tolerance (float): Size tolerance factor (0.3 = 30% tolerance)
134
+
135
+ Returns:
136
+ tuple[BuildingEntry | None, bool]: Best matching entry and whether it needs 90° rotation
137
+ """
138
+ # Start with buildings of the required category
139
+ candidates = self.by_category.get(category, [])
140
+ if not candidates:
141
+ return None, False
142
+
143
+ # Score each candidate and track orientation
144
+ scored_candidates = []
145
+ for entry in candidates:
146
+ score, needs_rotation = self._calculate_match_score_with_orientation(
147
+ entry, category, width, depth, tolerance
148
+ )
149
+ if score > 0: # Only consider viable matches
150
+ scored_candidates.append((score, entry, needs_rotation))
151
+
152
+ if not scored_candidates:
153
+ return None, False
154
+
155
+ # Return the highest scoring match with its orientation info
156
+ scored_candidates.sort(key=lambda x: x[0], reverse=True)
157
+ _, best_entry, needs_rotation = scored_candidates[0]
158
+ return best_entry, needs_rotation
159
+
160
+ def _calculate_match_score_with_orientation(
161
+ self,
162
+ entry: BuildingEntry,
163
+ category: str,
164
+ width: float | None,
165
+ depth: float | None,
166
+ tolerance: float,
167
+ ) -> tuple[float, bool]:
168
+ """Calculate a match score and determine orientation for a building entry.
169
+
170
+ Returns:
171
+ tuple[float, bool]: Match score and whether 90° rotation is needed
172
+ """
173
+ score = 0.0
174
+
175
+ # Category match (required) - base score
176
+ if category in entry.categories:
177
+ score = 100.0
178
+ else:
179
+ return 0.0, False # Category mismatch = no match
180
+
181
+ # Size matching (if dimensions are provided)
182
+ if width is not None and depth is not None:
183
+ # Calculate how well the dimensions match (considering both orientations)
184
+ size_score1 = self._calculate_size_match(
185
+ entry.width, entry.depth, width, depth, tolerance
186
+ )
187
+ size_score2 = self._calculate_size_match(
188
+ entry.width, entry.depth, depth, width, tolerance
189
+ )
190
+
191
+ # Determine which orientation is better
192
+ if size_score1 >= size_score2:
193
+ # Original orientation is better
194
+ if size_score1 > 0:
195
+ score += size_score1 * 80.0
196
+ return score, False
197
+ return 0.0, False
198
+ if size_score2 > 0:
199
+ score += size_score2 * 80.0
200
+ return score, True
201
+ return 0.0, False
202
+
203
+ return score, False
204
+
205
+ def _calculate_match_score(
206
+ self,
207
+ entry: BuildingEntry,
208
+ category: str,
209
+ width: float | None,
210
+ depth: float | None,
211
+ tolerance: float,
212
+ ) -> float:
213
+ """Calculate a match score for a building entry.
214
+ Region is matched during initialization unless ignore_region is True.
215
+
216
+ Returns:
217
+ float: Match score (higher is better, 0 means no match)
218
+ """
219
+ score = 0.0
220
+
221
+ # Category match (required) - base score
222
+ if category in entry.categories:
223
+ score = 100.0
224
+ else:
225
+ return 0.0 # Category mismatch = no match
226
+
227
+ # Size matching (if dimensions are provided)
228
+ if width is not None and depth is not None:
229
+ # Calculate how well the dimensions match (considering both orientations)
230
+ size_score1 = self._calculate_size_match(
231
+ entry.width, entry.depth, width, depth, tolerance
232
+ )
233
+ size_score2 = self._calculate_size_match(
234
+ entry.width, entry.depth, depth, width, tolerance
235
+ )
236
+
237
+ # Use the better orientation
238
+ size_score = max(size_score1, size_score2)
239
+
240
+ if size_score > 0:
241
+ score += (
242
+ size_score * 80.0
243
+ ) # Size match contributes up to 80 points (increased since no region bonus)
244
+ else:
245
+ return 0.0 # Size too different = no match
246
+
247
+ return score
248
+
249
+ def _calculate_size_match(
250
+ self,
251
+ entry_width: float,
252
+ entry_depth: float,
253
+ target_width: float,
254
+ target_depth: float,
255
+ tolerance: float,
256
+ ) -> float:
257
+ """Calculate how well building dimensions match target dimensions.
258
+
259
+ Returns:
260
+ float: Size match score between 0 and 1 (1 = perfect match)
261
+ """
262
+ width_ratio = min(entry_width, target_width) / max(entry_width, target_width)
263
+ depth_ratio = min(entry_depth, target_depth) / max(entry_depth, target_depth)
264
+
265
+ # Check if both dimensions are within tolerance
266
+ if width_ratio < (1 - tolerance) or depth_ratio < (1 - tolerance):
267
+ return 0.0
268
+
269
+ # Calculate combined size score (average of both dimension matches)
270
+ return (width_ratio + depth_ratio) / 2.0
271
+
272
+ def get_available_categories(self) -> list[str]:
273
+ """Get list of available building categories for this collection."""
274
+ return list(self.by_category.keys())
275
+
276
+ def filter_by_category(self, category: str) -> list[BuildingEntry]:
277
+ """Get all buildings of a specific category (filtered by region unless ignore_region is True)."""
278
+ return self.by_category.get(category, [])
279
+
280
+
281
+ def building_category_type_to_pixel(building_category: str) -> int | None:
282
+ """Returns the pixel value representation of the building category.
283
+ If not found, returns None.
284
+
285
+ Arguments:
286
+ building_category (str | None): The building category type as a string.
287
+
288
+ Returns:
289
+ int | None: pixel value of the building category, or None if not found.
290
+ """
291
+ return AREA_TYPES.get(building_category)
292
+
293
+
294
+ def pixel_value_to_building_category_type(pixel_value: int) -> str:
295
+ """Returns the building category type representation of the pixel value.
296
+ If not found, returns "residential".
297
+
298
+ Arguments:
299
+ pixel_value (int | None): The pixel value to look up the building category for.
300
+
301
+ Returns:
302
+ str: building category of the pixel value, or "residential" if not found.
303
+ """
304
+ return PIXEL_TYPES.get(pixel_value, "residential")
305
+
306
+
307
+ class Building(I3d):
308
+ """Component for map buildings processing and generation.
309
+
310
+ Arguments:
311
+ game (Game): The game instance for which the map is generated.
312
+ coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
313
+ map_size (int): The size of the map in pixels.
314
+ map_rotated_size (int): The size of the map in pixels after rotation.
315
+ rotation (int): The rotation angle of the map.
316
+ map_directory (str): The directory where the map files are stored.
317
+ logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
318
+ info, warning. If not provided, default logging will be used.
319
+ """
320
+
321
+ def preprocess(self) -> None:
322
+ """Preprocess and prepare buildings schema and buildings map image."""
323
+ try:
324
+ buildings_schema_path = self.game.buildings_schema
325
+ except ValueError as e:
326
+ self.logger.warning("The game does not support buildings schema: %s", e)
327
+ return
328
+
329
+ custom_buildings_schema = self.map.buildings_custom_schema
330
+ if not custom_buildings_schema:
331
+ if not os.path.isfile(buildings_schema_path):
332
+ self.logger.warning(
333
+ "Buildings schema file not found at path: %s. Skipping buildings generation.",
334
+ buildings_schema_path,
335
+ )
336
+ return
337
+
338
+ try:
339
+ with open(buildings_schema_path, "r", encoding="utf-8") as f:
340
+ buildings_schema = json.load(f)
341
+
342
+ self.buildings_schema = buildings_schema
343
+
344
+ except Exception as e:
345
+ self.logger.warning(
346
+ "Failed to load buildings schema from path: %s with error: %s. Skipping buildings generation.",
347
+ buildings_schema_path,
348
+ e,
349
+ )
350
+ else:
351
+ self.buildings_schema = custom_buildings_schema
352
+
353
+ self.logger.info(
354
+ "Buildings schema loaded successfully with %d objects.", len(self.buildings_schema)
355
+ )
356
+
357
+ self.xml_path = self.game.i3d_file_path(self.map_directory)
358
+
359
+ buildings_directory = os.path.join(self.map.map_directory, "buildings")
360
+ self.buildings_map_path = os.path.join(buildings_directory, "building_categories.png")
361
+ os.makedirs(buildings_directory, exist_ok=True)
362
+
363
+ texture_component = self.map.get_texture_component()
364
+ if not texture_component:
365
+ self.logger.warning("Texture component not found in the map.")
366
+ return
367
+
368
+ # Creating empty single-channel image for building categories.
369
+ buildings_map_image = np.zeros((self.map.size, self.map.size), dtype=np.uint8)
370
+
371
+ for layer in texture_component.get_building_category_layers():
372
+ self.logger.debug(
373
+ "Processing building category layer: %s (%s)",
374
+ layer.name,
375
+ layer.building_category,
376
+ )
377
+ pixel_value = building_category_type_to_pixel(layer.building_category) # type: ignore
378
+ if pixel_value is None:
379
+ self.logger.warning(
380
+ "Unknown building category '%s' for layer '%s'. Skipping.",
381
+ layer.building_category,
382
+ layer.name,
383
+ )
384
+ continue
385
+
386
+ layer_path = layer.path(self.game.weights_dir_path(self.map.map_directory))
387
+ if not layer_path or not os.path.isfile(layer_path):
388
+ self.logger.warning("Layer texture file not found: %s. Skipping.", layer_path)
389
+ continue
390
+
391
+ layer_image = cv2.imread(layer_path, cv2.IMREAD_UNCHANGED)
392
+ if layer_image is None:
393
+ self.logger.warning("Failed to read layer image: %s. Skipping.", layer_path)
394
+ continue
395
+
396
+ mask = layer_image > 0
397
+ buildings_map_image[mask] = pixel_value
398
+
399
+ # Save the buildings map image
400
+ cv2.imwrite(self.buildings_map_path, buildings_map_image)
401
+ self.logger.info("Building categories map saved to: %s", self.buildings_map_path)
402
+
403
+ building_entries = []
404
+ for building_entry in self.buildings_schema:
405
+ building = BuildingEntry(**building_entry)
406
+ building_entries.append(building)
407
+
408
+ ignore_region = False
409
+ region = ""
410
+
411
+ if self.map.building_settings.region == AUTO_REGION:
412
+ region = get_region_by_coordinates(self.coordinates)
413
+ elif self.map.building_settings.region == ALL_REGIONS:
414
+ ignore_region = True
415
+ region = "all" # Set a default region name for logging
416
+ else:
417
+ region = self.map.building_settings.region
418
+
419
+ self.buildings_collection = BuildingEntryCollection(building_entries, region, ignore_region)
420
+
421
+ if ignore_region:
422
+ self.logger.info(
423
+ "Buildings collection created with %d buildings ignoring region restrictions.",
424
+ len(self.buildings_collection.entries),
425
+ )
426
+ else:
427
+ self.logger.info(
428
+ "Buildings collection created with %d buildings for region '%s'.",
429
+ len(self.buildings_collection.entries),
430
+ region,
431
+ )
432
+
433
+ def process(self) -> None:
434
+ """Process and place buildings on the map."""
435
+ try:
436
+ self.add_buildings()
437
+ except Exception as e:
438
+ self.logger.warning("An error occurred during buildings processing: %s", e)
439
+
440
+ # pylint: disable=too-many-return-statements
441
+ def add_buildings(self) -> None:
442
+ """Process and place buildings on the map based on the buildings map image and schema."""
443
+ if not hasattr(self, "buildings_map_path") or not os.path.isfile(self.buildings_map_path):
444
+ self.logger.warning(
445
+ "Buildings map path is not set or file does not exist. Skipping process step."
446
+ )
447
+ return
448
+
449
+ # Check if the collection contains any buildings.
450
+ if not self.buildings_collection.entries:
451
+ self.logger.warning(
452
+ "No buildings found in the collection. Buildings generation will be skipped.",
453
+ )
454
+ return
455
+
456
+ buildings_map_image = cv2.imread(self.buildings_map_path, cv2.IMREAD_UNCHANGED)
457
+ if buildings_map_image is None:
458
+ self.logger.warning("Failed to read buildings map image. Skipping process step.")
459
+ return
460
+
461
+ self.logger.debug("Buildings map categories file found, processing...")
462
+
463
+ buildings = self.get_infolayer_data(Parameters.TEXTURES, Parameters.BUILDINGS)
464
+ if not buildings:
465
+ self.logger.warning("Buildings data not found in textures info layer.")
466
+ return
467
+
468
+ self.logger.info("Found %d building entries to process.", len(buildings))
469
+
470
+ # Initialize tracking for XML modifications
471
+ tree = self.get_tree()
472
+ root = tree.getroot()
473
+
474
+ if root is None:
475
+ self.logger.warning("Failed to get root element from I3D XML tree.")
476
+ return
477
+
478
+ # Find the Scene element
479
+ scene_node = root.find(".//Scene")
480
+ if scene_node is None:
481
+ self.logger.warning("Scene element not found in I3D file.")
482
+ return
483
+
484
+ # Find or create the Files section
485
+ files_section = root.find("Files")
486
+ if files_section is None:
487
+ files_section = ET.SubElement(root, "Files")
488
+
489
+ # Find or create the buildings transform group in the scene
490
+ buildings_group = self._find_or_create_buildings_group(scene_node)
491
+
492
+ # Track used building files to avoid duplicates (file_path -> file_id mapping)
493
+ used_building_files = {}
494
+ file_id_counter = BUILDINGS_STARTING_NODE_ID
495
+ node_id_counter = BUILDINGS_STARTING_NODE_ID + 1000
496
+
497
+ not_resized_dem = self.get_not_resized_dem_with_foundations(allow_fallback=True)
498
+ if not_resized_dem is None:
499
+ self.logger.warning("Not resized DEM not found.")
500
+ return
501
+
502
+ for building in tqdm(buildings, desc="Placing buildings", unit="building"):
503
+ try:
504
+ fitted_building = self.fit_object_into_bounds(
505
+ polygon_points=building, angle=self.rotation
506
+ )
507
+ except ValueError as e:
508
+ self.logger.debug(
509
+ "Building could not be fitted into the map bounds with error: %s",
510
+ e,
511
+ )
512
+ continue
513
+
514
+ # 1. Identify the center point of the building polygon.
515
+ center_point = np.mean(fitted_building, axis=0).astype(int)
516
+ x, y = center_point
517
+ self.logger.debug("Center point of building polygon: %s", center_point)
518
+
519
+ pixel_value = buildings_map_image[y, x]
520
+ self.logger.debug("Pixel value at center point: %s", pixel_value)
521
+
522
+ category = pixel_value_to_building_category_type(pixel_value)
523
+ self.logger.debug("Building category at center point: %s", category)
524
+
525
+ # 2. Obtain building dimensions and rotation using minimum area bounding rectangle
526
+ polygon_np = self.polygon_points_to_np(fitted_building)
527
+ width, depth, rotation_angle = self._get_polygon_dimensions_and_rotation(polygon_np)
528
+ self.logger.debug(
529
+ "Building dimensions: width=%d, depth=%d, rotation=%d°",
530
+ width,
531
+ depth,
532
+ rotation_angle,
533
+ )
534
+
535
+ # 3. Find the best matching building from the collection and determine orientation
536
+ best_match, needs_rotation = self.buildings_collection.find_best_match_with_orientation(
537
+ category=category,
538
+ width=width,
539
+ depth=depth,
540
+ tolerance=self.map.building_settings.tolerance_factor,
541
+ )
542
+
543
+ if best_match:
544
+ self.logger.debug(
545
+ "Best building match: %s: %d x %d, needs_rotation: %s",
546
+ best_match.name,
547
+ best_match.width,
548
+ best_match.depth,
549
+ needs_rotation,
550
+ )
551
+
552
+ # Get world coordinates
553
+ x_center, y_center = self.top_left_coordinates_to_center(center_point)
554
+ try:
555
+ z = self.get_z_coordinate_from_dem(not_resized_dem, x, y)
556
+ except Exception as e:
557
+ self.logger.warning(
558
+ "Failed to get Z coordinate from DEM at (%d, %d) with error: %s. Using default height %d.",
559
+ x,
560
+ y,
561
+ e,
562
+ DEFAULT_HEIGHT,
563
+ )
564
+ z = DEFAULT_HEIGHT
565
+
566
+ # * Disabled for now, maybe re-enable later.
567
+ # Calculate scale factors to match the polygon size
568
+ # scale_width = width / best_match.width
569
+ # scale_depth = depth / best_match.depth
570
+
571
+ self.logger.debug(
572
+ "World coordinates for building: x=%.3f, y=%.3f, z=%.3f",
573
+ x_center,
574
+ y_center,
575
+ z,
576
+ )
577
+ # self.logger.debug(
578
+ # "Scale factors: width=%.4f, depth=%.4f",
579
+ # scale_width,
580
+ # scale_depth,
581
+ # )
582
+
583
+ # Add building file to Files section if not already present
584
+ file_id = None
585
+ if best_match.file not in used_building_files:
586
+ file_id = file_id_counter
587
+ file_element = ET.SubElement(files_section, "File")
588
+ file_element.set("fileId", str(file_id))
589
+ file_element.set("filename", best_match.file)
590
+ used_building_files[best_match.file] = file_id
591
+ file_id_counter += 1
592
+ else:
593
+ file_id = used_building_files[best_match.file]
594
+
595
+ # Adjust rotation if the building needs to be rotated 90 degrees
596
+ final_rotation = rotation_angle
597
+ if needs_rotation:
598
+ final_rotation = (rotation_angle + 90.0) % 360.0
599
+ self.logger.debug(
600
+ "Building needs 90° rotation: original=%.1f°, final=%.1f°",
601
+ rotation_angle,
602
+ final_rotation,
603
+ )
604
+
605
+ # Create building instance in the buildings group
606
+ building_node = ET.SubElement(buildings_group, "ReferenceNode")
607
+ building_node.set("name", f"{best_match.name}_{node_id_counter}")
608
+ building_node.set("translation", f"{x_center:.3f} {z:.3f} {y_center:.3f}")
609
+ building_node.set("rotation", f"0 {final_rotation:.3f} 0")
610
+ # building_node.set(
611
+ # "scale", f"{scale_width:.4f} 1.0 {scale_depth:.4f}"
612
+ # )
613
+ building_node.set("referenceId", str(file_id))
614
+ building_node.set("nodeId", str(node_id_counter))
615
+
616
+ node_id_counter += 1
617
+
618
+ else:
619
+ self.logger.debug(
620
+ "No suitable building found for category '%s' with dimensions %.2fx%.2f",
621
+ category,
622
+ width,
623
+ depth,
624
+ needs_rotation,
625
+ )
626
+ continue
627
+
628
+ added_buildings_count = node_id_counter - (BUILDINGS_STARTING_NODE_ID + 1000)
629
+ self.logger.info("Total buildings placed: %d of %d", added_buildings_count, len(buildings))
630
+
631
+ # Save the modified XML tree
632
+ self.save_tree(tree)
633
+ self.logger.info("Buildings placement completed and saved to map.i3d")
634
+
635
+ def _get_polygon_dimensions_and_rotation(
636
+ self, polygon_points: np.ndarray
637
+ ) -> tuple[float, float, float]:
638
+ """Calculate width, depth, and rotation angle of a polygon using minimum area bounding rectangle.
639
+
640
+ Arguments:
641
+ polygon_points (np.ndarray): Array of polygon points with shape (n, 2)
642
+
643
+ Returns:
644
+ tuple[float, float, float]: width, depth, and rotation angle in degrees
645
+ """
646
+ # Convert to the format expected by cv2.minAreaRect (needs to be float32)
647
+ points = polygon_points.astype(np.float32)
648
+
649
+ # Find the minimum area bounding rectangle
650
+ rect = cv2.minAreaRect(points)
651
+
652
+ # rect contains: ((center_x, center_y), (width, height), angle)
653
+ (_, _), (width, height), angle = rect
654
+
655
+ # OpenCV's minAreaRect returns angle in range [-90, 0) for the longer side
656
+ # We need to convert this to a proper world rotation angle
657
+
658
+ # First, ensure width is the longer dimension
659
+ if width < height:
660
+ # Swap dimensions
661
+ width, height = height, width
662
+ # When we swap dimensions, we need to adjust the angle by 90 degrees
663
+ angle = angle + 90.0
664
+
665
+ # Convert OpenCV angle to world rotation angle
666
+ # OpenCV angle is measured from the horizontal axis, counter-clockwise
667
+ # But we want the angle in degrees for Y-axis rotation in 3D space
668
+ rotation_angle = -angle # Negative because 3D rotation is clockwise positive
669
+
670
+ # Normalize to [0, 360) range
671
+ while rotation_angle < 0:
672
+ rotation_angle += 360
673
+ while rotation_angle >= 360:
674
+ rotation_angle -= 360
675
+
676
+ return width, height, rotation_angle
677
+
678
+ def _find_or_create_buildings_group(self, scene_node: ET.Element) -> ET.Element:
679
+ """Find or create the buildings transform group in the scene.
680
+
681
+ Arguments:
682
+ scene_node (ET.Element): The scene element of the XML tree
683
+
684
+ Returns:
685
+ ET.Element: The buildings transform group element
686
+ """
687
+ # Look for existing buildings group in the scene
688
+ for transform_group in scene_node.iter("TransformGroup"):
689
+ if transform_group.get("name") == "buildings":
690
+ return transform_group
691
+
692
+ # Create new buildings group if not found using the proper element creation method
693
+ buildings_group = self.create_element(
694
+ "TransformGroup",
695
+ {
696
+ "name": "buildings",
697
+ "translation": "0 0 0",
698
+ "nodeId": str(BUILDINGS_STARTING_NODE_ID),
699
+ },
700
+ )
701
+
702
+ scene_node.append(buildings_group)
703
+ return buildings_group
704
+
705
+ def info_sequence(self) -> dict[str, dict[str, str | float | int]]:
706
+ return {}