maps4fs 2.8.9__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.
@@ -19,6 +19,7 @@ from maps4fs.generator.component.base.component_image import ImageComponent
19
19
  from maps4fs.generator.component.base.component_mesh import MeshComponent
20
20
  from maps4fs.generator.component.dem import DEM
21
21
  from maps4fs.generator.component.texture import Texture
22
+ from maps4fs.generator.monitor import monitor_performance
22
23
  from maps4fs.generator.settings import Parameters
23
24
 
24
25
  SEGMENT_LENGTH = 2
@@ -38,6 +39,7 @@ class Background(MeshComponent, ImageComponent):
38
39
  info, warning. If not provided, default logging will be used.
39
40
  """
40
41
 
42
+ @monitor_performance
41
43
  def preprocess(self) -> None:
42
44
  """Registers the DEMs for the background terrain."""
43
45
  self.stl_preview_path = os.path.join(self.previews_directory, "background_dem.stl")
@@ -76,6 +78,12 @@ class Background(MeshComponent, ImageComponent):
76
78
  self.background_directory, "not_substracted.png"
77
79
  )
78
80
  self.not_resized_path: str = os.path.join(self.background_directory, "not_resized.png")
81
+ self.not_resized_with_foundations_path: str = os.path.join(
82
+ self.background_directory, "not_resized_with_foundations.png"
83
+ )
84
+ self.not_resized_with_flattened_roads_path: str = os.path.join(
85
+ self.background_directory, "not_resized_with_flattened_roads.png"
86
+ )
79
87
 
80
88
  self.flatten_water_to: int | None = None
81
89
 
@@ -141,6 +149,7 @@ class Background(MeshComponent, ImageComponent):
141
149
  "Mesh processing is disabled for the game, skipping water mesh processing."
142
150
  )
143
151
 
152
+ @monitor_performance
144
153
  def create_foundations(self, dem_image: np.ndarray) -> np.ndarray:
145
154
  """Creates foundations for buildings based on the DEM data.
146
155
 
@@ -246,6 +255,7 @@ class Background(MeshComponent, ImageComponent):
246
255
  )
247
256
  self.create_qgis_scripts([qgis_layer, qgis_layer_with_margin])
248
257
 
258
+ @monitor_performance
249
259
  def generate_obj_files(self) -> None:
250
260
  """Iterates over all dems and generates 3D obj files based on DEM data.
251
261
  If at least one DEM file is missing, the generation will be stopped at all.
@@ -328,8 +338,9 @@ class Background(MeshComponent, ImageComponent):
328
338
  return resolution
329
339
  return Parameters.MAXIMUM_BACKGROUND_TEXTURE_SIZE
330
340
 
341
+ @monitor_performance
331
342
  def decimate_background_mesh(self) -> None:
332
- """ ""Decimates the background mesh based on the map size."""
343
+ """Decimates the background mesh based on the map size."""
333
344
  if not self.assets.background_mesh or not os.path.isfile(self.assets.background_mesh):
334
345
  self.logger.warning("Background mesh not found, cannot generate i3d background.")
335
346
  return
@@ -362,6 +373,7 @@ class Background(MeshComponent, ImageComponent):
362
373
 
363
374
  self.assets.decimated_background_mesh = decimated_save_path
364
375
 
376
+ @monitor_performance
365
377
  def texture_background_mesh(self) -> None:
366
378
  """Textures the background mesh using satellite imagery."""
367
379
  satellite_component = self.map.get_satellite_component()
@@ -427,6 +439,7 @@ class Background(MeshComponent, ImageComponent):
427
439
  self.logger.error("Could not texture background mesh: %s", e)
428
440
  return
429
441
 
442
+ @monitor_performance
430
443
  def convert_background_mesh_to_i3d(self) -> bool:
431
444
  """Converts the textured background mesh to i3d format.
432
445
 
@@ -498,6 +511,7 @@ class Background(MeshComponent, ImageComponent):
498
511
  with open(file_path, "w", encoding="utf-8") as f:
499
512
  f.write(content + "\n\n" + note)
500
513
 
514
+ @monitor_performance
501
515
  def convert_water_mesh_to_i3d(self) -> bool:
502
516
  """Converts the line-based water mesh to i3d format.
503
517
 
@@ -553,6 +567,10 @@ class Background(MeshComponent, ImageComponent):
553
567
 
554
568
  if self.map.dem_settings.add_foundations:
555
569
  dem_data = self.create_foundations(dem_data)
570
+ cv2.imwrite(self.not_resized_with_foundations_path, dem_data)
571
+ self.logger.debug(
572
+ "Not resized DEM with foundations saved: %s", self.not_resized_with_foundations_path
573
+ )
556
574
 
557
575
  output_size = self.scaled_size + 1
558
576
 
@@ -730,6 +748,7 @@ class Background(MeshComponent, ImageComponent):
730
748
  cv2.imwrite(colored_dem_path, dem_data_colored)
731
749
  return colored_dem_path
732
750
 
751
+ @monitor_performance
733
752
  def create_background_textures(self) -> None:
734
753
  """Creates background textures for the map."""
735
754
  layers_schema = self.map.texture_schema
@@ -782,6 +801,7 @@ class Background(MeshComponent, ImageComponent):
782
801
 
783
802
  cv2.imwrite(self.water_resources_path, background_image)
784
803
 
804
+ @monitor_performance
785
805
  def subtraction(self) -> None:
786
806
  """Subtracts the water depth from the DEM data where the water resources are located."""
787
807
  if not self.water_resources_path:
@@ -806,6 +826,10 @@ class Background(MeshComponent, ImageComponent):
806
826
 
807
827
  if self.map.background_settings.flatten_water:
808
828
  try:
829
+ # Check if there are any water pixels (255) in the water resources image.
830
+ if not np.any(water_resources_image == 255):
831
+ self.logger.warning("No water pixels found in water resources image.")
832
+ return
809
833
  mask = water_resources_image == 255
810
834
  flatten_to = int(np.mean(dem_image[mask]) - subtract_by) # type: ignore
811
835
  self.flatten_water_to = flatten_to # type: ignore
@@ -845,13 +869,14 @@ class Background(MeshComponent, ImageComponent):
845
869
  """
846
870
  self.logger.debug("Starting line-based water generation...")
847
871
  water_polygons = self.get_infolayer_data(Parameters.BACKGROUND, Parameters.WATER)
848
- self.logger.debug(
849
- "Found %s water polygons in background info layer.", len(water_polygons) # type: ignore
850
- )
851
872
  if not water_polygons:
852
873
  self.logger.warning("No water polygons found in background info layer.")
853
874
  return
854
875
 
876
+ self.logger.debug(
877
+ "Found %s water polygons in background info layer.", len(water_polygons) # type: ignore
878
+ )
879
+
855
880
  polygons: list[shapely.Polygon] = []
856
881
  for polygon_points in water_polygons:
857
882
  if not polygon_points or len(polygon_points) < 2:
@@ -967,6 +992,7 @@ class Background(MeshComponent, ImageComponent):
967
992
  mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
968
993
  return mesh
969
994
 
995
+ @monitor_performance
970
996
  def generate_water_resources_obj(self) -> None:
971
997
  """Generates 3D obj files based on water resources data."""
972
998
  self.logger.debug("Starting water resources generation...")
@@ -1033,13 +1059,24 @@ class Background(MeshComponent, ImageComponent):
1033
1059
 
1034
1060
  self.plane_from_np(elevated_water, elevated_save_path, include_zeros=False)
1035
1061
 
1062
+ @monitor_performance
1036
1063
  def flatten_roads(self) -> None:
1037
1064
  """Flattens the roads in the DEM data by averaging the height values along the road polylines."""
1038
- if not self.not_resized_path or not os.path.isfile(self.not_resized_path):
1065
+ supported_files = [self.not_resized_with_foundations_path, self.not_resized_path]
1066
+
1067
+ base_image_path = None
1068
+ for supported_file in supported_files:
1069
+ if not supported_file or not os.path.isfile(supported_file):
1070
+ continue
1071
+
1072
+ base_image_path = supported_file
1073
+ break
1074
+
1075
+ if not base_image_path:
1039
1076
  self.logger.warning("No DEM data found for flattening roads.")
1040
1077
  return
1041
1078
 
1042
- dem_image = cv2.imread(self.not_resized_path, cv2.IMREAD_UNCHANGED)
1079
+ dem_image = cv2.imread(base_image_path, cv2.IMREAD_UNCHANGED)
1043
1080
  if dem_image is None:
1044
1081
  self.logger.warning("Failed to read DEM data.")
1045
1082
  return
@@ -1076,46 +1113,92 @@ class Background(MeshComponent, ImageComponent):
1076
1113
  total_length = polyline.length
1077
1114
  self.logger.debug("Total length of the road polyline: %s", total_length)
1078
1115
 
1079
- current_distance = 0
1080
- segments: list[shapely.LineString] = []
1081
- while current_distance < total_length:
1082
- start_point = polyline.interpolate(current_distance)
1083
- end_distance = min(current_distance + SEGMENT_LENGTH, total_length)
1084
- end_point = polyline.interpolate(end_distance)
1116
+ # Step 1: Create complete road mask once
1117
+ road_mask = np.zeros(dem_image.shape, dtype=np.uint8)
1118
+ # OpenCV thickness is total width, not radius like Shapely buffer
1119
+ # So we need width * 4 to match the old buffer(width * 2) behavior
1120
+ line_thickness = int(width * 4)
1121
+
1122
+ # Get densely sampled points for smooth road
1123
+ dense_sample_distance = min(SEGMENT_LENGTH, total_length / 100) # At least 100 samples
1124
+ num_dense_points = max(100, int(total_length / dense_sample_distance))
1125
+ dense_distances = np.linspace(0, total_length, num_dense_points)
1126
+ dense_points = [polyline.interpolate(d) for d in dense_distances]
1127
+ dense_coords = np.array([(int(p.x), int(p.y)) for p in dense_points], dtype=np.int32)
1128
+
1129
+ # Draw entire road at once
1130
+ if len(dense_coords) > 1:
1131
+ cv2.polylines(road_mask, [dense_coords], False, 255, thickness=line_thickness)
1132
+
1133
+ # Step 2: Get all road pixels that need processing
1134
+ road_pixels = np.where(road_mask == 255)
1135
+ if len(road_pixels[0]) == 0:
1136
+ continue
1085
1137
 
1086
- # Create a small segment LineString
1087
- segment = shapely.LineString([start_point, end_point])
1088
- segments.append(segment)
1089
- current_distance += SEGMENT_LENGTH
1138
+ road_y, road_x = road_pixels
1139
+ self.logger.debug("Processing %s road pixels", len(road_y))
1140
+
1141
+ # Step 3: Efficient distance-based smooth gradation
1142
+ # Use much larger segments (10-20x SEGMENT_LENGTH) but interpolate between them
1143
+ large_segment_length = SEGMENT_LENGTH * 15 # 30 units instead of 2
1144
+ num_large_segments = max(1, int(np.ceil(total_length / large_segment_length)))
1145
+ large_distances = np.linspace(0, total_length, num_large_segments + 1)
1146
+
1147
+ # Calculate elevation values at large segment points
1148
+ segment_elevations = []
1149
+ for dist in large_distances:
1150
+ sample_point = polyline.interpolate(dist)
1151
+ sample_x, sample_y = int(sample_point.x), int(sample_point.y)
1152
+
1153
+ # Sample elevation in small area around this point
1154
+ sample_radius = max(5, line_thickness // 4)
1155
+ y_min = max(0, sample_y - sample_radius)
1156
+ y_max = min(dem_image.shape[0], sample_y + sample_radius)
1157
+ x_min = max(0, sample_x - sample_radius)
1158
+ x_max = min(dem_image.shape[1], sample_x + sample_radius)
1159
+
1160
+ if y_max > y_min and x_max > x_min:
1161
+ sample_elevation = np.mean(dem_image[y_min:y_max, x_min:x_max]) # type: ignore
1162
+ else:
1163
+ sample_elevation = (
1164
+ dem_image[sample_y, sample_x] # type: ignore
1165
+ if 0 <= sample_y < dem_image.shape[0] and 0 <= sample_x < dem_image.shape[1]
1166
+ else 0
1167
+ )
1090
1168
 
1091
- self.logger.debug("Number of segments created: %s", len(segments))
1169
+ segment_elevations.append(sample_elevation)
1092
1170
 
1093
- road_polygons: list[shapely.Polygon] = []
1094
- for segment in segments:
1095
- polygon = segment.buffer(
1096
- width * 2, resolution=4, cap_style="flat", join_style="mitre"
1097
- )
1098
- road_polygons.append(polygon)
1171
+ # Step 4: Interpolate elevations smoothly along entire road
1172
+ road_distances_from_start = []
1173
+ for i in range(len(road_y)): # pylint: disable=consider-using-enumerate
1174
+ px, py = road_x[i], road_y[i]
1175
+ # Find closest point on polyline (approximation)
1176
+ closest_point = polyline.interpolate(polyline.project(shapely.Point(px, py)))
1177
+ distance_along_road = polyline.project(closest_point)
1178
+ road_distances_from_start.append(distance_along_road)
1099
1179
 
1100
- for polygon in road_polygons:
1101
- polygon_points = polygon.exterior.coords
1102
- road_np = self.polygon_points_to_np(polygon_points)
1103
- mask = np.zeros(dem_image.shape, dtype=np.uint8)
1180
+ road_distances_from_start = np.array(road_distances_from_start) # type: ignore
1104
1181
 
1105
- try:
1106
- cv2.fillPoly(mask, [road_np], 255) # type: ignore
1107
- except Exception as e:
1108
- self.logger.debug("Could not create mask for road with error: %s", e)
1109
- continue
1182
+ # Interpolate elevation values based on distance along road
1183
+ interpolated_elevations = np.interp(
1184
+ road_distances_from_start, large_distances, segment_elevations
1185
+ )
1110
1186
 
1111
- mean_value = cv2.mean(dem_image, mask=mask)[0] # type: ignore
1112
- dem_image[mask == 255] = mean_value
1113
- full_mask[mask == 255] = 255
1187
+ # Step 5: Apply interpolated elevations to road pixels
1188
+ dem_image[road_y, road_x] = interpolated_elevations
1189
+ full_mask[road_y, road_x] = 255
1114
1190
 
1115
1191
  main_dem_path = self.game.dem_file_path(self.map_directory)
1116
1192
  dem_image = self.blur_by_mask(dem_image, full_mask, blur_radius=5)
1117
1193
  dem_image = self.blur_edges_by_mask(dem_image, full_mask)
1118
1194
 
1195
+ # Save the not resized DEM with flattened roads.
1196
+ cv2.imwrite(self.not_resized_with_flattened_roads_path, dem_image)
1197
+ self.logger.debug(
1198
+ "Not resized DEM with flattened roads saved to: %s",
1199
+ self.not_resized_with_flattened_roads_path,
1200
+ )
1201
+
1119
1202
  output_size = dem_image.shape[0] + 1
1120
1203
  resized_dem = cv2.resize(
1121
1204
  dem_image, (output_size, output_size), interpolation=cv2.INTER_NEAREST
@@ -595,19 +595,45 @@ class MeshComponent(Component):
595
595
  if has_uv:
596
596
  xml_vertices.set("uv0", "true")
597
597
 
598
- # Write vertex data
599
- for idx in tqdm(range(len(vertices)), desc="Writing vertices", unit="vertex"):
600
- v = vertices[idx]
601
- v_el = ET.SubElement(xml_vertices, "v")
602
- v_el.set("p", f"{v[0]:.6f} {v[1]:.6f} {v[2]:.6f}")
603
-
604
- if has_normals:
605
- n = mesh.vertex_normals[idx]
606
- v_el.set("n", f"{n[0]:.6f} {n[1]:.6f} {n[2]:.6f}")
607
-
608
- if has_uv:
609
- uv = mesh.visual.uv[idx]
610
- v_el.set("t0", f"{uv[0]:.6f} {uv[1]:.6f}")
598
+ # Pre-format ALL strings using vectorized operations
599
+ pos_strings = np.array([f"{v[0]:.6f} {v[1]:.6f} {v[2]:.6f}" for v in vertices])
600
+
601
+ normal_strings = None
602
+ if has_normals:
603
+ normal_strings = np.array(
604
+ [f"{n[0]:.6f} {n[1]:.6f} {n[2]:.6f}" for n in mesh.vertex_normals]
605
+ )
606
+
607
+ uv_strings = None
608
+ if has_uv:
609
+ uv_strings = np.array([f"{uv[0]:.6f} {uv[1]:.6f}" for uv in mesh.visual.uv])
610
+
611
+ # Batch process vertices for memory efficiency
612
+ batch_size = 2000
613
+ vertex_elements = []
614
+
615
+ for batch_start in tqdm(
616
+ range(0, len(vertices), batch_size), desc="Writing vertices", unit="batch"
617
+ ):
618
+ batch_end = min(batch_start + batch_size, len(vertices))
619
+ batch_elements = []
620
+
621
+ for idx in range(batch_start, batch_end):
622
+ v_el = ET.Element("v")
623
+ v_el.set("p", pos_strings[idx])
624
+
625
+ if has_normals:
626
+ v_el.set("n", normal_strings[idx]) # type: ignore
627
+
628
+ if has_uv:
629
+ v_el.set("t0", uv_strings[idx]) # type: ignore
630
+
631
+ batch_elements.append(v_el)
632
+
633
+ vertex_elements.extend(batch_elements)
634
+
635
+ # Add all vertex elements at once
636
+ xml_vertices.extend(vertex_elements)
611
637
 
612
638
  # Triangles block
613
639
  xml_tris = ET.SubElement(shape, "Triangles")