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.
@@ -0,0 +1,648 @@
1
+ """Component for map roads processing and generation."""
2
+
3
+ import os
4
+ import shutil
5
+ from collections import defaultdict
6
+ from typing import NamedTuple
7
+
8
+ import numpy as np
9
+ import shapely
10
+ import trimesh
11
+ from shapely.geometry import Point
12
+
13
+ import maps4fs.generator.config as mfscfg
14
+ from maps4fs.generator.component.base.component_mesh import MeshComponent
15
+ from maps4fs.generator.component.i3d import I3d
16
+ from maps4fs.generator.settings import Parameters
17
+
18
+ PATCH_Z_OFFSET = -0.001
19
+
20
+
21
+ class RoadEntry(NamedTuple):
22
+ """Data structure representing a road entry with its linestring, width, and optional z-offset."""
23
+
24
+ linestring: shapely.LineString
25
+ width: int
26
+ z_offset: float = 0.0
27
+
28
+
29
+ class Road(I3d, MeshComponent):
30
+ """Component for map roads processing and generation.
31
+
32
+ Arguments:
33
+ game (Game): The game instance for which the map is generated.
34
+ coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
35
+ map_size (int): The size of the map in pixels.
36
+ map_rotated_size (int): The size of the map in pixels after rotation.
37
+ rotation (int): The rotation angle of the map.
38
+ map_directoryPara (str): The directory where the map files are stored.
39
+ logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
40
+ info, warning. If not provided, default logging will be used.
41
+ """
42
+
43
+ def preprocess(self) -> None:
44
+ """Preprocess the road data before generation."""
45
+
46
+ def process(self):
47
+ """Process and generate roads for the map."""
48
+ try:
49
+ self.generate_roads()
50
+ except Exception as e:
51
+ self.logger.error("Error during road generation: %s", e)
52
+
53
+ def generate_roads(self) -> None:
54
+ """Generate roads for the map based on the info layer data."""
55
+ road_infos = self.get_infolayer_data(Parameters.TEXTURES, Parameters.ROADS_POLYLINES)
56
+ if not road_infos:
57
+ self.logger.warning("Roads polylines data not found in textures info layer.")
58
+ return
59
+
60
+ roads_by_texture = defaultdict(list)
61
+ for road_info in road_infos: # type: ignore
62
+ road_texture = road_info.get("road_texture")
63
+ if road_texture:
64
+ roads_by_texture[road_texture].append(road_info)
65
+
66
+ for texture, roads_polylines in roads_by_texture.items():
67
+ self.logger.info("Processing roads with texture: %s", texture)
68
+
69
+ # The texture name is represents the name of texture file without extension
70
+ # for easy reference if the texture uses various extensions.
71
+ # E.g. 'asphalt', 'gravel' -> 'asphalt.png', 'gravel.jpg', etc.
72
+
73
+ road_entries: list[RoadEntry] = []
74
+ for road_id, road_info in enumerate(roads_polylines, start=1): # type: ignore
75
+ if isinstance(road_info, dict):
76
+ points: list[int | float] = road_info.get("points") # type: ignore
77
+ width: int = road_info.get("width") # type: ignore
78
+ else:
79
+ continue
80
+
81
+ if not points or len(points) < 2 or not width:
82
+ self.logger.debug("Invalid road data for road ID %s: %s", road_id, road_info)
83
+ continue
84
+
85
+ try:
86
+ fitted_road = self.fit_object_into_bounds(
87
+ linestring_points=points, angle=self.rotation # type: ignore
88
+ )
89
+ except ValueError as e:
90
+ self.logger.debug(
91
+ "Road %s could not be fitted into the map bounds with error: %s",
92
+ road_id,
93
+ e,
94
+ )
95
+ continue
96
+
97
+ try:
98
+ linestring = shapely.LineString(fitted_road)
99
+ except ValueError as e:
100
+ self.logger.debug(
101
+ "Road %s could not be converted to a LineString with error: %s",
102
+ road_id,
103
+ e,
104
+ )
105
+ continue
106
+
107
+ road_entries.append(RoadEntry(linestring=linestring, width=width))
108
+
109
+ self.logger.info("Total found for mesh generation: %d", len(road_entries))
110
+
111
+ if road_entries:
112
+ # 1. Apply smart interpolation to make linestrings smoother,
113
+ # but carefully, ensuring that points are not too close to each other.
114
+ # Otherwise it may lead to artifacts in the mesh.
115
+ interpolated_road_entries: list[RoadEntry] = self.smart_interpolation(road_entries)
116
+
117
+ # 2. Split roads that exceed Giants Engine's UV coordinate limits
118
+ # Giants Engine requires UV coordinates in [-32, 32] range
119
+ split_road_entries: list[RoadEntry] = self.split_long_roads(
120
+ interpolated_road_entries
121
+ )
122
+
123
+ patches_road_entries: list[RoadEntry] = self.get_patches_linestrings(
124
+ split_road_entries
125
+ )
126
+ split_road_entries.extend(patches_road_entries)
127
+ self.generate_road_mesh(split_road_entries, texture)
128
+
129
+ def smart_interpolation(self, road_entries: list[RoadEntry]) -> list[RoadEntry]:
130
+ """Apply smart interpolation to road linestrings.
131
+ Making sure that result polylines do not have points too close to each other.
132
+
133
+ Arguments:
134
+ road_entries (list[RoadEntry]): List of RoadEntry objects
135
+
136
+ Returns:
137
+ (list[RoadEntry]): List of RoadEntry objects with interpolated linestrings.
138
+ """
139
+ interpolated_entries = []
140
+ target_segment_length = 5 # Target distance between points in meters (denser)
141
+ max_angle_change = 30.0 # Maximum angle change in degrees to allow interpolation
142
+
143
+ for linestring, width, z_offset in road_entries:
144
+ coords = list(linestring.coords)
145
+ if len(coords) < 2:
146
+ interpolated_entries.append(RoadEntry(linestring, width, z_offset))
147
+ continue
148
+
149
+ # Check if road has sharp curves - if so, skip interpolation
150
+ has_sharp_curves = False
151
+ if len(coords) >= 3:
152
+ for i in range(1, len(coords) - 1):
153
+ # Calculate angle change at this point
154
+ v1_x = coords[i][0] - coords[i - 1][0]
155
+ v1_y = coords[i][1] - coords[i - 1][1]
156
+ v2_x = coords[i + 1][0] - coords[i][0]
157
+ v2_y = coords[i + 1][1] - coords[i][1]
158
+
159
+ # Calculate angle between vectors
160
+ dot = v1_x * v2_x + v1_y * v2_y
161
+ len1 = np.sqrt(v1_x**2 + v1_y**2)
162
+ len2 = np.sqrt(v2_x**2 + v2_y**2)
163
+
164
+ if len1 > 0 and len2 > 0:
165
+ cos_angle = np.clip(dot / (len1 * len2), -1.0, 1.0)
166
+ angle_deg = np.degrees(np.arccos(cos_angle))
167
+
168
+ if angle_deg > max_angle_change:
169
+ has_sharp_curves = True
170
+ break
171
+
172
+ if has_sharp_curves:
173
+ # Skip interpolation for curved roads
174
+ interpolated_entries.append(RoadEntry(linestring, width, z_offset))
175
+ continue
176
+
177
+ # Check if interpolation is needed
178
+ needs_interpolation = False
179
+ for i in range(len(coords) - 1):
180
+ segment_length = np.sqrt(
181
+ (coords[i + 1][0] - coords[i][0]) ** 2 + (coords[i + 1][1] - coords[i][1]) ** 2
182
+ )
183
+ if segment_length > target_segment_length * 1.5:
184
+ needs_interpolation = True
185
+ break
186
+
187
+ if not needs_interpolation:
188
+ # Road is already dense enough
189
+ interpolated_entries.append(RoadEntry(linestring, width, z_offset))
190
+ continue
191
+
192
+ # Perform interpolation using shapely's interpolate (follows curves)
193
+ road_length = linestring.length
194
+ num_points = int(np.ceil(road_length / target_segment_length)) + 1
195
+
196
+ new_coords = []
197
+ for i in range(num_points):
198
+ distance = min(i * target_segment_length, road_length)
199
+ point = linestring.interpolate(distance)
200
+ new_coords.append((point.x, point.y))
201
+
202
+ # Ensure last point is exact
203
+ if new_coords[-1] != coords[-1]:
204
+ new_coords.append(coords[-1])
205
+
206
+ # Create new linestring with interpolated coordinates
207
+ # No cleanup needed - interpolation already creates evenly spaced points
208
+ try:
209
+ interpolated_linestring = shapely.LineString(new_coords)
210
+ interpolated_entries.append(RoadEntry(interpolated_linestring, width, z_offset))
211
+ except Exception as e:
212
+ self.logger.warning(
213
+ "Failed to create interpolated linestring: %s. Using original.", e
214
+ )
215
+ interpolated_entries.append(RoadEntry(linestring, width, z_offset))
216
+
217
+ self.logger.info(
218
+ "Smart interpolation complete. Processed %d roads.", len(interpolated_entries)
219
+ )
220
+ return interpolated_entries
221
+
222
+ def split_long_roads(
223
+ self, road_entries: list[RoadEntry], texture_tile_size: float = 10.0
224
+ ) -> list[RoadEntry]:
225
+ """Split roads that exceed Giants Engine's UV coordinate limits.
226
+
227
+ Giants Engine requires UV coordinates to be in [-32, 32] range.
228
+ Roads longer than 32 * texture_tile_size meters need to be split.
229
+
230
+ Arguments:
231
+ road_entries (list[RoadEntry]): List of RoadEntry objects
232
+ texture_tile_size (float): Size of texture tile in meters
233
+
234
+ Returns:
235
+ (list[RoadEntry]): List of RoadEntry objects with long roads split.
236
+ """
237
+ max_road_length = 30.0 * texture_tile_size # Use 30 instead of 32 for safety margin
238
+ split_entries = []
239
+
240
+ for linestring, width, z_offset in road_entries:
241
+ road_length = linestring.length
242
+
243
+ if road_length <= max_road_length:
244
+ # Road is short enough, keep as is
245
+ split_entries.append(RoadEntry(linestring, width, z_offset))
246
+ continue
247
+
248
+ # Road is too long, split it into segments
249
+ num_segments = int(np.ceil(road_length / max_road_length))
250
+ segment_length = road_length / num_segments
251
+
252
+ self.logger.info(
253
+ "Splitting road (%.2fm) into %d segments of ~%.2fm each",
254
+ road_length,
255
+ num_segments,
256
+ segment_length,
257
+ )
258
+
259
+ for i in range(num_segments):
260
+ start_distance = i * segment_length
261
+ end_distance = min((i + 1) * segment_length, road_length)
262
+
263
+ # Extract segment using shapely's substring
264
+ try:
265
+ segment_linestring = shapely.ops.substring(
266
+ linestring, start_distance, end_distance, normalized=False
267
+ )
268
+ split_entries.append(RoadEntry(segment_linestring, width, z_offset))
269
+ self.logger.debug(
270
+ " Segment %d: %.2fm to %.2fm (length: %.2fm)",
271
+ i,
272
+ start_distance,
273
+ end_distance,
274
+ segment_linestring.length,
275
+ )
276
+ except Exception as e:
277
+ self.logger.warning("Failed to split road segment %d: %s", i, e)
278
+
279
+ self.logger.info(
280
+ "Road splitting complete: %d roads -> %d segments",
281
+ len(road_entries),
282
+ len(split_entries),
283
+ )
284
+ return split_entries
285
+
286
+ def get_patches_linestrings(self, road_entries: list[RoadEntry]) -> list[RoadEntry]:
287
+ """Generate patch segments for T-junction intersections.
288
+
289
+ This method identifies T-junctions where one road ends at another road,
290
+ and creates patch segments from the continuous (main) road to overlay
291
+ the intersection and prevent z-fighting.
292
+
293
+ Arguments:
294
+ road_entries (list[RoadEntry]): List of RoadEntry objects
295
+
296
+ Returns:
297
+ (list[RoadEntry]): List of patch RoadEntry objects to be added.
298
+ """
299
+ patches = []
300
+ tolerance = 1.0 # Distance tolerance for endpoint intersection detection
301
+ cumulative_offset = PATCH_Z_OFFSET
302
+
303
+ # Process each road to find T-junctions
304
+ for idx, (road, _, _) in enumerate(road_entries):
305
+ # Get the endpoints of this road
306
+ start_point = Point(road.coords[0])
307
+ end_point = Point(road.coords[-1])
308
+
309
+ # Check if either endpoint intersects with another road's middle
310
+ for other_idx, (other_road, other_width, other_z_offset) in enumerate(road_entries):
311
+ if idx == other_idx:
312
+ continue
313
+
314
+ # Check both endpoints
315
+ for endpoint in [start_point, end_point]:
316
+ # Check if endpoint is near the other road (but not at its endpoints)
317
+ distance = endpoint.distance(other_road)
318
+
319
+ if distance < tolerance:
320
+ # This is a potential T-junction
321
+ # Make sure it's not connecting at the other road's endpoints
322
+ other_start = Point(other_road.coords[0])
323
+ other_end = Point(other_road.coords[-1])
324
+
325
+ # Skip if connecting at endpoints (this is a proper intersection, not T)
326
+ if (
327
+ endpoint.distance(other_start) < tolerance
328
+ or endpoint.distance(other_end) < tolerance
329
+ ):
330
+ continue
331
+
332
+ # Find the closest point on the other road
333
+ intersection_point = other_road.interpolate(other_road.project(endpoint))
334
+
335
+ # Find which segment of other_road contains this intersection
336
+ coords = list(other_road.coords)
337
+ segment_idx = None
338
+
339
+ for i in range(len(coords) - 1):
340
+ segment = shapely.LineString([coords[i], coords[i + 1]])
341
+ if segment.distance(intersection_point) < tolerance:
342
+ segment_idx = i
343
+ break
344
+
345
+ if segment_idx is None:
346
+ continue
347
+
348
+ # Create patch: take 2 points before and 2 points after the intersection
349
+ # Ensure we don't go out of bounds
350
+ start_idx = max(0, segment_idx - 2)
351
+ end_idx = min(len(coords) - 1, segment_idx + 3)
352
+
353
+ # Need at least 2 points for a valid linestring
354
+ if end_idx - start_idx < 1:
355
+ continue
356
+
357
+ # Extract the patch segment
358
+ patch_coords = coords[start_idx : end_idx + 1]
359
+
360
+ try:
361
+ patch_linestring = shapely.LineString(patch_coords)
362
+ patch_z_offset = other_z_offset + cumulative_offset
363
+ cumulative_offset += PATCH_Z_OFFSET
364
+ path_road_entry = RoadEntry(
365
+ linestring=patch_linestring,
366
+ width=other_width,
367
+ z_offset=patch_z_offset,
368
+ )
369
+ patches.append(path_road_entry)
370
+ self.logger.debug(
371
+ "Created patch for T-junction: road %d intersects road %d",
372
+ idx,
373
+ other_idx,
374
+ )
375
+ except Exception as e:
376
+ self.logger.debug("Failed to create patch linestring: %s", e)
377
+ continue
378
+
379
+ self.logger.info("Generated %d patch segments for T-junctions", len(patches))
380
+ return patches
381
+
382
+ def find_texture_file(self, templates_directory: str, texture_base_name: str) -> str:
383
+ """Finds the texture file with supported extensions in the templates directory.
384
+
385
+ Arguments:
386
+ templates_directory (str): The directory where texture files are stored.
387
+ texture_base_name (str): The base name of the texture file without extension.
388
+
389
+ Returns:
390
+ (str): The full path to the found texture file.
391
+ """
392
+ for ext in [".png", ".jpg", ".jpeg", ".dds"]:
393
+ texture_path = os.path.join(templates_directory, texture_base_name + ext).lower()
394
+ if os.path.isfile(texture_path):
395
+ return texture_path
396
+ raise FileNotFoundError(
397
+ f"Texture file for base name {texture_base_name} not found in {templates_directory}."
398
+ )
399
+
400
+ def generate_road_mesh(self, road_entries: list[RoadEntry], texture: str) -> None:
401
+ """Generates the road mesh from linestrings and saves it as an I3D asset.
402
+
403
+ Arguments:
404
+ road_entries (list[RoadEntry]): List of RoadEntry objects to generate the mesh from.
405
+ texture (str): The base name of the texture file to use for the roads.
406
+ """
407
+ road_mesh_directory = os.path.join(self.map_directory, "roads", texture)
408
+ os.makedirs(road_mesh_directory, exist_ok=True)
409
+
410
+ try:
411
+ texture_path = self.find_texture_file(mfscfg.MFS_TEMPLATES_DIR, texture)
412
+ except FileNotFoundError as e:
413
+ self.logger.warning("Texture file not found: %s", e)
414
+ return
415
+
416
+ dst_texture_path = os.path.join(
417
+ road_mesh_directory,
418
+ os.path.basename(texture_path), # From templates/asphalt.png -> asphalt.png.
419
+ )
420
+
421
+ shutil.copyfile(texture_path, dst_texture_path)
422
+ self.logger.info("Texture copied to %s", dst_texture_path)
423
+
424
+ obj_output_path = os.path.join(road_mesh_directory, f"roads_{texture}.obj")
425
+ mtl_output_path = os.path.join(road_mesh_directory, f"roads_{texture}.mtl")
426
+
427
+ self.create_textured_linestrings_mesh(
428
+ road_entries=road_entries,
429
+ texture_path=dst_texture_path,
430
+ obj_output_path=obj_output_path,
431
+ mtl_output_path=mtl_output_path,
432
+ )
433
+
434
+ # Load the mesh but preserve_order to maintain UV mapping
435
+ mesh = trimesh.load_mesh(obj_output_path, force="mesh", process=False)
436
+ rotation_matrix = trimesh.transformations.rotation_matrix(np.pi / 2, [1, 0, 0])
437
+ mesh.apply_transform(rotation_matrix)
438
+
439
+ vertices = mesh.vertices
440
+ center = vertices.mean(axis=0)
441
+ mesh.vertices = vertices - center
442
+
443
+ output_directory = os.path.join(self.map_directory, "assets", "roads", texture)
444
+ os.makedirs(output_directory, exist_ok=True)
445
+
446
+ self.mesh_to_i3d(mesh, output_directory, f"roads_{texture}", texture_path=dst_texture_path)
447
+
448
+ def create_textured_linestrings_mesh(
449
+ self,
450
+ road_entries: list[RoadEntry],
451
+ texture_path: str,
452
+ obj_output_path: str,
453
+ mtl_output_path: str,
454
+ ) -> None:
455
+ """Creates a textured mesh from linestrings with varying widths.
456
+
457
+ This method generates a 3D mesh for roads by:
458
+ 1. Creating rectangular strips along each linestring based on its width
459
+ 2. Applying proper UV mapping for tiled texture along the road length
460
+ 3. Exporting the mesh to OBJ format with corresponding MTL material file
461
+
462
+ Arguments:
463
+ linestrings: List of tuples containing (shapely.LineString, width in meters)
464
+ texture_path: Path to the texture image file to apply
465
+ obj_output_path: Output path for the OBJ mesh file
466
+ mtl_output_path: Output path for the MTL material file
467
+ """
468
+ # Use the not resized DEM with flattened roads to get accurate Z values
469
+ # for the road mesh vertices.
470
+ not_resized_dem = self.get_not_resized_dem_with_flattened_roads()
471
+ if not_resized_dem is None:
472
+ self.logger.warning(
473
+ "Not resized DEM with flattened roads is not available. "
474
+ "Cannot generate road mesh."
475
+ )
476
+ return
477
+
478
+ vertices = []
479
+ faces = []
480
+ uvs = []
481
+ vertex_offset = 0
482
+
483
+ texture_tile_size = 10.0 # meters - how many meters before texture repeats
484
+
485
+ patches_count = sum(1 for entry in road_entries if entry.z_offset > 0)
486
+ self.logger.info(
487
+ "Creating mesh for %d roads (%d patches with z-offset)",
488
+ len(road_entries),
489
+ patches_count,
490
+ )
491
+
492
+ for _, (linestring, width, z_offset) in enumerate(road_entries):
493
+ coords = list(linestring.coords)
494
+ if len(coords) < 2:
495
+ continue
496
+
497
+ # Generate road strip vertices
498
+ segment_vertices = []
499
+ segment_uvs = []
500
+ accumulated_distance = 0.0
501
+ prev_center_3d: tuple[float, float, float] | None = (
502
+ None # Track previous center point in 3D
503
+ )
504
+
505
+ for i in range(len(coords)): # pylint: disable=consider-using-enumerate
506
+ x, y = coords[i]
507
+
508
+ # Calculate direction vector for perpendicular offset
509
+ if i == 0:
510
+ # First point: use direction to next point
511
+ dx = coords[i + 1][0] - coords[i][0]
512
+ dy = coords[i + 1][1] - coords[i][1]
513
+ elif i == len(coords) - 1:
514
+ # Last point: use direction from previous point
515
+ dx = coords[i][0] - coords[i - 1][0]
516
+ dy = coords[i][1] - coords[i - 1][1]
517
+ else:
518
+ # Middle points: average direction
519
+ dx1 = coords[i][0] - coords[i - 1][0]
520
+ dy1 = coords[i][1] - coords[i - 1][1]
521
+ dx2 = coords[i + 1][0] - coords[i][0]
522
+ dy2 = coords[i + 1][1] - coords[i][1]
523
+ dx = (dx1 + dx2) / 2.0
524
+ dy = (dy1 + dy2) / 2.0
525
+
526
+ # Normalize direction and get perpendicular
527
+ length = np.sqrt(dx * dx + dy * dy)
528
+ if length > 0:
529
+ dx /= length
530
+ dy /= length
531
+
532
+ # Perpendicular vector (rotated 90 degrees)
533
+ perp_x = -dy
534
+ perp_y = dx
535
+
536
+ exact_z_value = self.get_z_coordinate_from_dem(not_resized_dem, x, y)
537
+ offsetted_z = exact_z_value + z_offset
538
+
539
+ # Create left and right vertices with z-offset
540
+ left_vertex = (x + perp_x * width, y + perp_y * width, offsetted_z)
541
+ right_vertex = (x - perp_x * width, y - perp_y * width, offsetted_z)
542
+
543
+ segment_vertices.append(left_vertex)
544
+ segment_vertices.append(right_vertex)
545
+
546
+ # Calculate UV coordinates based on 3D distance (including Z changes)
547
+ # U coordinate: 0 for left edge, 1 for right edge
548
+ # V coordinate: based on accumulated 3D distance along the road
549
+ segment_distance_3d = 0.0
550
+ current_center_3d = (x, y, offsetted_z)
551
+
552
+ # pylint: disable=unsubscriptable-object
553
+ if i > 0 and prev_center_3d is not None:
554
+ # Calculate both 2D and 3D distances for comparison
555
+ segment_distance_3d = np.sqrt(
556
+ (current_center_3d[0] - prev_center_3d[0]) ** 2
557
+ + (current_center_3d[1] - prev_center_3d[1]) ** 2
558
+ + (current_center_3d[2] - prev_center_3d[2]) ** 2
559
+ )
560
+ accumulated_distance += segment_distance_3d
561
+
562
+ prev_center_3d = current_center_3d
563
+
564
+ # Calculate V coordinate - divide by texture tile size
565
+ v_coord_raw = accumulated_distance / texture_tile_size
566
+
567
+ # Store raw V coordinate for now - we'll apply modulo to the entire road later
568
+ segment_uvs.append((0.0, v_coord_raw)) # Left edge
569
+ segment_uvs.append((1.0, v_coord_raw)) # Right edge
570
+
571
+ # Add vertices and UVs to global lists
572
+ vertices.extend(segment_vertices)
573
+ uvs.extend(segment_uvs)
574
+
575
+ # Create faces (triangles) for the road strip
576
+ num_segments = len(coords) - 1
577
+ for i in range(num_segments):
578
+ # Each segment creates 2 triangles (a quad)
579
+ # Vertex indices for this segment
580
+ v0 = vertex_offset + i * 2 # Left vertex of current segment
581
+ v1 = vertex_offset + i * 2 + 1 # Right vertex of current segment
582
+ v2 = vertex_offset + (i + 1) * 2 # Left vertex of next segment
583
+ v3 = vertex_offset + (i + 1) * 2 + 1 # Right vertex of next segment
584
+
585
+ # First triangle (counter-clockwise winding)
586
+ faces.append((v0, v2, v1))
587
+ # Second triangle
588
+ faces.append((v1, v2, v3))
589
+
590
+ vertex_offset += len(segment_vertices)
591
+
592
+ if not vertices:
593
+ self.logger.warning("No vertices generated for road mesh.")
594
+ return
595
+
596
+ # Write MTL file
597
+ mtl_filename = os.path.basename(mtl_output_path)
598
+ texture_filename = os.path.basename(texture_path)
599
+
600
+ with open(mtl_output_path, "w", encoding="utf-8") as mtl_file:
601
+ mtl_file.write("# Road material\n")
602
+ mtl_file.write("newmtl RoadMaterial\n")
603
+ mtl_file.write("Ka 1.0 1.0 1.0\n") # Ambient color
604
+ mtl_file.write("Kd 1.0 1.0 1.0\n") # Diffuse color
605
+ mtl_file.write("Ks 0.3 0.3 0.3\n") # Specular color
606
+ mtl_file.write("Ns 10.0\n") # Specular exponent
607
+ mtl_file.write("illum 2\n") # Illumination model
608
+ mtl_file.write(f"map_Kd {texture_filename}\n") # Diffuse texture map
609
+
610
+ self.logger.info("MTL file written to %s", mtl_output_path)
611
+
612
+ # Write OBJ file
613
+ with open(obj_output_path, "w", encoding="utf-8") as obj_file:
614
+ obj_file.write("# Road mesh generated by maps4fs\n")
615
+ obj_file.write(f"mtllib {mtl_filename}\n\n")
616
+
617
+ # Write vertices
618
+ obj_file.write(f"# {len(vertices)} vertices\n")
619
+ for v in vertices:
620
+ obj_file.write(f"v {v[0]:.6f} {v[1]:.6f} {v[2]:.6f}\n")
621
+
622
+ # Write UV coordinates
623
+ obj_file.write(f"\n# {len(uvs)} texture coordinates\n")
624
+ for uv in uvs:
625
+ obj_file.write(f"vt {uv[0]:.6f} {uv[1]:.6f}\n")
626
+
627
+ # Write faces with material
628
+ obj_file.write(f"\n# {len(faces)} faces\n")
629
+ obj_file.write("usemtl RoadMaterial\n")
630
+ for face in faces:
631
+ # OBJ format uses 1-based indexing
632
+ # Format: f v1/vt1 v2/vt2 v3/vt3
633
+ obj_file.write(
634
+ f"f {face[0] + 1}/{face[0] + 1} "
635
+ f"{face[1] + 1}/{face[1] + 1} "
636
+ f"{face[2] + 1}/{face[2] + 1}\n"
637
+ )
638
+
639
+ self.logger.info(
640
+ "OBJ file written to %s with %d vertices and %d faces",
641
+ obj_output_path,
642
+ len(vertices),
643
+ len(faces),
644
+ )
645
+
646
+ def info_sequence(self):
647
+ """Returns information about the component."""
648
+ return {}
@@ -9,6 +9,7 @@ from pygmdl import save_image
9
9
 
10
10
  import maps4fs.generator.config as mfscfg
11
11
  from maps4fs.generator.component.base.component_image import ImageComponent
12
+ from maps4fs.generator.monitor import monitor_performance
12
13
  from maps4fs.generator.settings import Parameters
13
14
 
14
15
 
@@ -49,6 +50,7 @@ class Satellite(ImageComponent):
49
50
  info, warning. If not provided, default logging will be used.
50
51
  """
51
52
 
53
+ @monitor_performance
52
54
  def process(self) -> None:
53
55
  """Downloads the satellite images for the map."""
54
56
  self.image_paths = []
@@ -126,6 +128,7 @@ class Satellite(ImageComponent):
126
128
 
127
129
  return tasks
128
130
 
131
+ @monitor_performance
129
132
  def previews(self) -> list[str]:
130
133
  """Returns the paths to the preview images.
131
134