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.
- maps4fs/generator/component/background.py +18 -3
- maps4fs/generator/component/building.py +706 -0
- maps4fs/generator/component/grle.py +57 -0
- maps4fs/generator/component/i3d.py +59 -5
- maps4fs/generator/component/layer.py +19 -0
- maps4fs/generator/component/road.py +648 -0
- maps4fs/generator/component/texture.py +16 -4
- maps4fs/generator/config.py +79 -11
- maps4fs/generator/game.py +64 -3
- maps4fs/generator/map.py +3 -0
- maps4fs/generator/settings.py +18 -1
- maps4fs/generator/utils.py +15 -1
- {maps4fs-2.9.2.dist-info → maps4fs-2.9.37.dist-info}/METADATA +6 -4
- maps4fs-2.9.37.dist-info/RECORD +32 -0
- maps4fs-2.9.37.dist-info/licenses/LICENSE.md +416 -0
- maps4fs-2.9.2.dist-info/RECORD +0 -30
- maps4fs-2.9.2.dist-info/licenses/LICENSE.md +0 -651
- {maps4fs-2.9.2.dist-info → maps4fs-2.9.37.dist-info}/WHEEL +0 -0
- {maps4fs-2.9.2.dist-info → maps4fs-2.9.37.dist-info}/top_level.txt +0 -0
|
@@ -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 {}
|