maps4fs 2.8.2__py3-none-any.whl → 2.8.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of maps4fs might be problematic. Click here for more details.

@@ -6,7 +6,7 @@ from __future__ import annotations
6
6
  import os
7
7
  import shutil
8
8
  from copy import deepcopy
9
- from typing import Any
9
+ from typing import Any, Literal
10
10
 
11
11
  import cv2
12
12
  import numpy as np
@@ -57,6 +57,14 @@ class Background(MeshComponent, ImageComponent):
57
57
  os.makedirs(self.background_directory, exist_ok=True)
58
58
  os.makedirs(self.water_directory, exist_ok=True)
59
59
 
60
+ self.textured_mesh_directory = os.path.join(self.background_directory, "textured_mesh")
61
+ os.makedirs(self.textured_mesh_directory, exist_ok=True)
62
+
63
+ self.assets_background_directory = os.path.join(self.map.assets_directory, "background")
64
+ self.assets_water_directory = os.path.join(self.map.assets_directory, "water")
65
+ os.makedirs(self.assets_background_directory, exist_ok=True)
66
+ os.makedirs(self.assets_water_directory, exist_ok=True)
67
+
60
68
  self.water_resources_path = os.path.join(self.water_directory, "water_resources.png")
61
69
 
62
70
  self.output_path = os.path.join(self.background_directory, f"{Parameters.FULL}.png")
@@ -110,8 +118,28 @@ class Background(MeshComponent, ImageComponent):
110
118
 
111
119
  if self.map.background_settings.generate_background:
112
120
  self.generate_obj_files()
121
+ if self.game.mesh_processing:
122
+ self.logger.debug("Mesh processing is enabled, will decimate, texture and convert.")
123
+ self.decimate_background_mesh()
124
+ self.texture_background_mesh()
125
+ background_conversion_result = self.convert_background_mesh_to_i3d()
126
+ if background_conversion_result:
127
+ self.add_note_file(asset="background")
128
+ else:
129
+ self.logger.warning(
130
+ "Mesh processing is disabled for the game, skipping background mesh processing."
131
+ )
113
132
  if self.map.background_settings.generate_water:
114
133
  self.generate_water_resources_obj()
134
+ if self.game.mesh_processing:
135
+ self.logger.debug("Mesh processing is enabled, will convert water mesh to i3d.")
136
+ water_conversion_result = self.convert_water_mesh_to_i3d()
137
+ if water_conversion_result:
138
+ self.add_note_file(asset="water")
139
+ else:
140
+ self.logger.warning(
141
+ "Mesh processing is disabled for the game, skipping water mesh processing."
142
+ )
115
143
 
116
144
  def create_foundations(self, dem_image: np.ndarray) -> np.ndarray:
117
145
  """Creates foundations for buildings based on the DEM data.
@@ -253,6 +281,257 @@ class Background(MeshComponent, ImageComponent):
253
281
  remove_center=self.map.background_settings.remove_center,
254
282
  )
255
283
 
284
+ @staticmethod
285
+ def get_decimate_factor(map_size: int) -> float:
286
+ """Returns the decimation factor based on the map size.
287
+
288
+ Arguments:
289
+ map_size (int): The size of the map in pixels.
290
+
291
+ Raises:
292
+ ValueError: If the map size is too large for decimation.
293
+
294
+ Returns:
295
+ float -- The decimation factor.
296
+ """
297
+ thresholds = {
298
+ 2048: 0.1,
299
+ 4096: 0.05,
300
+ 8192: 0.025,
301
+ 16384: 0.0125,
302
+ }
303
+ for threshold, factor in thresholds.items():
304
+ if map_size <= threshold:
305
+ return factor
306
+ raise ValueError(
307
+ "Map size is too large for decimation, perform manual decimation in Blender."
308
+ )
309
+
310
+ @staticmethod
311
+ def get_background_texture_resolution(map_size: int) -> int:
312
+ """Returns the background texture resolution based on the map size.
313
+
314
+ Arguments:
315
+ map_size (int): The size of the map in pixels.
316
+
317
+ Returns:
318
+ int -- The background texture resolution.
319
+ """
320
+ resolutions = {
321
+ 2048: 2048,
322
+ 4096: Parameters.MAXIMUM_BACKGROUND_TEXTURE_SIZE,
323
+ 8192: Parameters.MAXIMUM_BACKGROUND_TEXTURE_SIZE,
324
+ 16384: Parameters.MAXIMUM_BACKGROUND_TEXTURE_SIZE,
325
+ }
326
+ for threshold, resolution in resolutions.items():
327
+ if map_size <= threshold:
328
+ return resolution
329
+ return Parameters.MAXIMUM_BACKGROUND_TEXTURE_SIZE
330
+
331
+ def decimate_background_mesh(self) -> None:
332
+ """ ""Decimates the background mesh based on the map size."""
333
+ if not self.assets.background_mesh or not os.path.isfile(self.assets.background_mesh):
334
+ self.logger.warning("Background mesh not found, cannot generate i3d background.")
335
+ return
336
+
337
+ try:
338
+ mesh = trimesh.load_mesh(self.assets.background_mesh, force="mesh")
339
+ except Exception as e:
340
+ self.logger.error("Could not load background mesh: %s", e)
341
+ return
342
+
343
+ try:
344
+ decimate_factor = self.get_decimate_factor(self.map_size)
345
+ except ValueError as e:
346
+ self.logger.error("Could not determine decimation factor: %s", e)
347
+ return
348
+
349
+ decimated_save_path = os.path.join(
350
+ self.background_directory, f"{Parameters.DECIMATED_BACKGROUND}.obj"
351
+ )
352
+ try:
353
+ self.logger.debug("Decimating background mesh with factor %s.", decimate_factor)
354
+ decimated_mesh = self.decimate_mesh(mesh, decimate_factor)
355
+ self.logger.debug("Decimation completed.")
356
+ except Exception as e:
357
+ self.logger.error("Could not decimate background mesh: %s", e)
358
+ return
359
+
360
+ decimated_mesh.export(decimated_save_path)
361
+ self.logger.debug("Decimated background mesh saved: %s", decimated_save_path)
362
+
363
+ self.assets.decimated_background_mesh = decimated_save_path
364
+
365
+ def texture_background_mesh(self) -> None:
366
+ """Textures the background mesh using satellite imagery."""
367
+ satellite_component = self.map.get_satellite_component()
368
+ if not satellite_component:
369
+ self.logger.warning("Satellite component not found, cannot texture background mesh.")
370
+ return
371
+
372
+ background_texture_path = satellite_component.assets.background
373
+
374
+ if not background_texture_path or not os.path.isfile(background_texture_path):
375
+ self.logger.warning("Background texture not found, cannot texture background mesh.")
376
+ return
377
+
378
+ decimated_background_mesh_path = self.assets.decimated_background_mesh
379
+ if not decimated_background_mesh_path or not os.path.isfile(decimated_background_mesh_path):
380
+ self.logger.warning(
381
+ "Decimated background mesh not found, cannot texture background mesh."
382
+ )
383
+ return
384
+
385
+ background_texture_resolution = self.get_background_texture_resolution(self.map_size)
386
+ non_resized_texture_image = cv2.imread(background_texture_path, cv2.IMREAD_UNCHANGED)
387
+
388
+ if non_resized_texture_image is None:
389
+ self.logger.error(
390
+ "Failed to read background texture image: %s", background_texture_path
391
+ )
392
+ return
393
+
394
+ resized_texture_image = cv2.resize(
395
+ non_resized_texture_image, # type: ignore
396
+ (background_texture_resolution, background_texture_resolution),
397
+ interpolation=cv2.INTER_AREA,
398
+ )
399
+
400
+ resized_texture_save_path = os.path.join(
401
+ self.textured_mesh_directory,
402
+ "background_texture.jpg",
403
+ )
404
+
405
+ cv2.imwrite(resized_texture_save_path, resized_texture_image)
406
+ self.logger.debug("Resized background texture saved: %s", resized_texture_save_path)
407
+
408
+ decimated_mesh = trimesh.load_mesh(decimated_background_mesh_path, force="mesh")
409
+
410
+ if decimated_mesh is None:
411
+ self.logger.error("Failed to load decimated mesh after all retry attempts")
412
+ return
413
+
414
+ try:
415
+ obj_save_path, mtl_save_path = self.texture_mesh(
416
+ decimated_mesh,
417
+ resized_texture_save_path,
418
+ output_directory=self.textured_mesh_directory,
419
+ output_name="background_textured_mesh",
420
+ )
421
+
422
+ self.assets.textured_background_mesh = obj_save_path
423
+ self.assets.textured_background_mtl = mtl_save_path
424
+ self.assets.resized_background_texture = resized_texture_save_path
425
+ self.logger.debug("Textured background mesh saved: %s", obj_save_path)
426
+ except Exception as e:
427
+ self.logger.error("Could not texture background mesh: %s", e)
428
+ return
429
+
430
+ def convert_background_mesh_to_i3d(self) -> bool:
431
+ """Converts the textured background mesh to i3d format.
432
+
433
+ Returns:
434
+ bool -- True if the conversion was successful, False otherwise.
435
+ """
436
+ if not self.assets.textured_background_mesh or not os.path.isfile(
437
+ self.assets.textured_background_mesh
438
+ ):
439
+ self.logger.warning("Textured background mesh not found, cannot convert to i3d.")
440
+ return False
441
+
442
+ if not self.assets.resized_background_texture or not os.path.isfile(
443
+ self.assets.resized_background_texture
444
+ ):
445
+ self.logger.warning("Resized background texture not found, cannot convert to i3d.")
446
+ return False
447
+
448
+ try:
449
+ mesh = trimesh.load_mesh(self.assets.textured_background_mesh, force="mesh")
450
+ except Exception as e:
451
+ self.logger.error("Could not load textured background mesh: %s", e)
452
+ return False
453
+
454
+ try:
455
+ i3d_background_terrain = self.mesh_to_i3d(
456
+ mesh,
457
+ output_dir=self.assets_background_directory,
458
+ name=Parameters.BACKGROUND_TERRAIN,
459
+ texture_path=self.assets.resized_background_texture,
460
+ water_mesh=False,
461
+ )
462
+ self.logger.debug(
463
+ "Background mesh converted to i3d successfully: %s", i3d_background_terrain
464
+ )
465
+ self.assets.background_terrain_i3d = i3d_background_terrain
466
+ return True
467
+ except Exception as e:
468
+ self.logger.error("Could not convert background mesh to i3d: %s", e)
469
+ return False
470
+
471
+ def add_note_file(self, asset: Literal["background", "water"]) -> None:
472
+ """Adds a note file to the background or water directory.
473
+
474
+ Arguments:
475
+ asset (Literal["background", "water"]): The asset type to add the note file to.
476
+ """
477
+ filename = "DO_NOT_USE_THESE_FILES.txt"
478
+ note_template = (
479
+ "Please find the ready-to-use {asset} i3d files in the {asset_directory} directory."
480
+ )
481
+ directory = {
482
+ "background": self.background_directory,
483
+ "water": self.water_directory,
484
+ }
485
+
486
+ content = (
487
+ "The files in this directory can be used to create the mesh files manually in Blender. "
488
+ "However, it's recommended to use the ready-to-use i3d files located in the assets "
489
+ "directory. There you'll find the i3d files, that can be imported directly into the "
490
+ "Giants Editor without any additional processing."
491
+ )
492
+ note = note_template.format(
493
+ asset=asset,
494
+ asset_directory=f"assets/{asset}",
495
+ )
496
+
497
+ file_path = os.path.join(directory[asset], filename)
498
+ with open(file_path, "w", encoding="utf-8") as f:
499
+ f.write(content + "\n\n" + note)
500
+
501
+ def convert_water_mesh_to_i3d(self) -> bool:
502
+ """Converts the line-based water mesh to i3d format.
503
+
504
+ Returns:
505
+ bool -- True if the conversion was successful, False otherwise.
506
+ """
507
+ if not self.assets.line_based_water_mesh or not os.path.isfile(
508
+ self.assets.line_based_water_mesh
509
+ ):
510
+ self.logger.warning("Line-based water mesh not found, cannot convert to i3d.")
511
+ return False
512
+
513
+ try:
514
+ mesh = trimesh.load_mesh(self.assets.line_based_water_mesh, force="mesh")
515
+ except Exception as e:
516
+ self.logger.error("Could not load line-based water mesh: %s", e)
517
+ return False
518
+
519
+ try:
520
+ i3d_water_resources = self.mesh_to_i3d(
521
+ mesh,
522
+ output_dir=self.assets_water_directory,
523
+ name=Parameters.WATER_RESOURCES,
524
+ water_mesh=True,
525
+ )
526
+ self.logger.debug(
527
+ "Water resources mesh converted to i3d successfully: %s", i3d_water_resources
528
+ )
529
+ self.assets.water_resources_i3d = i3d_water_resources
530
+ return True
531
+ except Exception as e:
532
+ self.logger.error("Could not convert water mesh to i3d: %s", e)
533
+ return False
534
+
256
535
  def save_map_dem(self, dem_path: str, save_path: str | None = None) -> str:
257
536
  """Cuts out the center of the DEM (the actual map) and saves it as a separate file.
258
537
 
@@ -623,6 +902,8 @@ class Background(MeshComponent, ImageComponent):
623
902
  mesh.export(line_based_save_path)
624
903
  self.logger.debug("Line-based water mesh saved to %s", line_based_save_path)
625
904
 
905
+ self.assets.line_based_water_mesh = line_based_save_path
906
+
626
907
  def mesh_from_3d_polygons(
627
908
  self, polygons: list[shapely.Polygon], single_z_value: int | None = None
628
909
  ) -> Trimesh | None:
@@ -22,12 +22,31 @@ if TYPE_CHECKING:
22
22
 
23
23
 
24
24
  class AttrDict(dict):
25
- """A dictionary that allows attribute-style access to its keys."""
25
+ """A dictionary that allows attribute-style access to its keys.
26
+ Allows safe access to non-existing keys, returning None instead of raising KeyError.
27
+ """
28
+
29
+ def __getattr__(self, name: str) -> Any | None:
30
+ """Returns the value of the given key or None if the key does not exist.
26
31
 
27
- def __getattr__(self, name):
28
- return self[name]
32
+ Arguments:
33
+ name (str): The key to retrieve.
29
34
 
30
- def __setattr__(self, name, value):
35
+ Returns:
36
+ Any | None: The value of the key or None if the key does not exist.
37
+ """
38
+ try:
39
+ return self[name]
40
+ except KeyError:
41
+ return None
42
+
43
+ def __setattr__(self, name: str, value: Any) -> None:
44
+ """Sets the value of the given key.
45
+
46
+ Arguments:
47
+ name (str): The key to set.
48
+ value (Any): The value to set.
49
+ """
31
50
  self[name] = value
32
51
 
33
52
 
@@ -4,6 +4,7 @@ import os
4
4
 
5
5
  import cv2
6
6
  import numpy as np
7
+ from PIL import Image, ImageFile
7
8
 
8
9
  from maps4fs.generator.component.base.component import Component
9
10
  from maps4fs.generator.settings import Parameters
@@ -237,3 +238,35 @@ class ImageComponent(Component):
237
238
  edge_mask = cv2.subtract(bigger_mask, smaller_mask)
238
239
 
239
240
  return self.blur_by_mask(data, edge_mask)
241
+
242
+ @staticmethod
243
+ def convert_png_to_dds(input_png_path: str, output_dds_path: str):
244
+ """Convert a PNG file to DDS format using PIL
245
+
246
+ Arguments:
247
+ input_png_path (str): Path to input PNG file
248
+ output_dds_path (str): Path for output DDS file
249
+
250
+ Raises:
251
+ FileNotFoundError: If the input PNG file does not exist.
252
+ RuntimeError: If the DDS conversion fails.
253
+ """
254
+ if not os.path.exists(input_png_path):
255
+ raise FileNotFoundError(f"Input PNG file not found: {input_png_path}")
256
+
257
+ try:
258
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
259
+
260
+ with Image.open(input_png_path) as img:
261
+ # Convert to RGB if needed (DDS works better with RGB)
262
+ if img.mode == "RGBA":
263
+ # Create RGB version on white background
264
+ rgb_img = Image.new("RGB", img.size, (255, 255, 255))
265
+ rgb_img.paste(img, mask=img.split()[-1]) # Use alpha as mask
266
+ img = rgb_img
267
+ elif img.mode != "RGB":
268
+ img = img.convert("RGB")
269
+
270
+ img.save(output_dds_path, format="DDS")
271
+ except Exception as e:
272
+ raise RuntimeError(f"DDS conversion failed: {e}")
@@ -1,10 +1,15 @@
1
1
  """Base class for all components that primarily used to work with meshes."""
2
2
 
3
3
  import os
4
+ import shutil
5
+ import xml.etree.ElementTree as ET
6
+ from datetime import datetime
4
7
 
5
8
  import cv2
6
9
  import numpy as np
10
+ import open3d as o3d
7
11
  import trimesh
12
+ from PIL import Image
8
13
  from tqdm import tqdm
9
14
 
10
15
  from maps4fs.generator.component.base.component import Component
@@ -269,3 +274,405 @@ class MeshComponent(Component):
269
274
  mesh_copy = mesh.copy()
270
275
  mesh_copy.faces = mesh_copy.faces[:, ::-1] # type: ignore
271
276
  return mesh_copy
277
+
278
+ @staticmethod
279
+ def decimate_mesh(mesh: trimesh.Trimesh, reduction_factor: float) -> trimesh.Trimesh:
280
+ """Decimate mesh using Open3D's quadric decimation (similar to Blender's approach)
281
+
282
+ Arguments:
283
+ mesh (trimesh.Trimesh): Input trimesh mesh
284
+ reduction_factor (float): Reduce to this fraction of original triangles (0.5 = 50%)
285
+
286
+ Returns:
287
+ trimesh.Trimesh: Decimated trimesh mesh
288
+ """
289
+ # 1. Convert trimesh to Open3D format.
290
+ vertices = mesh.vertices
291
+ faces = mesh.faces
292
+
293
+ o3d_mesh = o3d.geometry.TriangleMesh()
294
+ o3d_mesh.vertices = o3d.utility.Vector3dVector(vertices)
295
+ o3d_mesh.triangles = o3d.utility.Vector3iVector(faces)
296
+
297
+ # 2. Calculate target number of triangles.
298
+ target_triangles = int(len(faces) * reduction_factor)
299
+
300
+ # 3. Apply quadric decimation.
301
+ decimated_o3d = o3d_mesh.simplify_quadric_decimation(target_triangles)
302
+
303
+ # 4. Convert back to trimesh.
304
+ decimated_vertices = np.asarray(decimated_o3d.vertices)
305
+ decimated_faces = np.asarray(decimated_o3d.triangles)
306
+ decimated_mesh = trimesh.Trimesh(vertices=decimated_vertices, faces=decimated_faces)
307
+
308
+ return decimated_mesh
309
+
310
+ @staticmethod
311
+ def texture_mesh(
312
+ mesh: trimesh.Trimesh,
313
+ resized_texture_path: str,
314
+ output_directory: str,
315
+ output_name: str,
316
+ ) -> tuple[str, str]:
317
+ """Apply texture to mesh with UV mapping based on X and Z coordinates (ground plane).
318
+
319
+ Arguments:
320
+ mesh (trimesh.Trimesh): The mesh to texture
321
+ resized_texture_path (str): Path to resized texture image
322
+ output_directory (str): Directory to save textured OBJ and MTL files and texture.
323
+ output_name (str): Base name for output files (without extension)
324
+
325
+ Returns:
326
+ tuple[str, str]: Paths to the saved OBJ and MTL files
327
+ """
328
+ # 1. Copy texture to output directory (only if not already there).
329
+ texture_filename = os.path.basename(resized_texture_path)
330
+ texture_output_path = os.path.join(output_directory, texture_filename)
331
+
332
+ # Check if source and destination are the same file to avoid copy conflicts
333
+ if os.path.abspath(resized_texture_path) != os.path.abspath(texture_output_path):
334
+ shutil.copy2(resized_texture_path, texture_output_path)
335
+
336
+ # 2. Apply rotation to fix 90-degree X-axis rotation.
337
+ rotation_matrix = trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0])
338
+ mesh.apply_transform(rotation_matrix)
339
+
340
+ # 3. Get mesh bounds: using X and Z for ground plane.
341
+ vertices = mesh.vertices
342
+
343
+ # 4. Ground plane coordinates (X, Z)
344
+ min_x = np.min(vertices[:, 0]) # X coordinate
345
+ max_x = np.max(vertices[:, 0])
346
+ min_z = np.min(vertices[:, 2]) # Z coordinate
347
+ max_z = np.max(vertices[:, 2])
348
+
349
+ width = max_x - min_x # X dimension
350
+ depth = max_z - min_z # Z dimension
351
+
352
+ # 5. Load texture.
353
+ texture_image = Image.open(texture_output_path)
354
+
355
+ # 6. Calculate UV coordinates based on X and Z positions.
356
+ uv_coords = np.zeros((len(vertices), 2), dtype=np.float32)
357
+ for i in tqdm(range(len(vertices)), desc="Calculating UVs", unit="vertex"):
358
+ vertex = vertices[i]
359
+
360
+ # Map X coordinate to U
361
+ u = (vertex[0] - min_x) / width if width > 0 else 0.5
362
+
363
+ # Map Z coordinate to V (NOT Y!)
364
+ v = (vertex[2] - min_z) / depth if depth > 0 else 0.5
365
+
366
+ # Flip V coordinate for correct orientation
367
+ v = 1.0 - v
368
+
369
+ # Clamp to valid range
370
+ u = np.clip(u, 0.0, 1.0)
371
+ v = np.clip(v, 0.0, 1.0)
372
+
373
+ uv_coords[i] = [u, v]
374
+
375
+ # 7. Create material.
376
+ material = trimesh.visual.material.PBRMaterial(
377
+ baseColorTexture=texture_image,
378
+ metallicFactor=0.0,
379
+ roughnessFactor=1.0,
380
+ emissiveFactor=[0.0, 0.0, 0.0],
381
+ )
382
+
383
+ # 8. Apply UV and material to mesh.
384
+ visual = trimesh.visual.TextureVisuals(uv=uv_coords, material=material)
385
+ mesh.visual = visual
386
+
387
+ mtl_filename = f"{output_name}.mtl"
388
+ obj_filename = f"{output_name}.obj"
389
+ mtl_filepath = os.path.join(output_directory, mtl_filename)
390
+ obj_filepath = os.path.join(output_directory, obj_filename)
391
+
392
+ faces = mesh.faces
393
+
394
+ # 9. Write OBJ file with correct UV mapping.
395
+ with open(obj_filepath, "w") as f: # pylint: disable=unspecified-encoding
396
+ f.write("# Corrected UV mapping using X,Z coordinates (ground plane)\n")
397
+ f.write("# Y coordinate represents elevation\n")
398
+ f.write(f"mtllib {os.path.basename(mtl_filename)}\n")
399
+
400
+ # Write vertices
401
+ for vertex in vertices:
402
+ f.write(f"v {vertex[0]:.6f} {vertex[1]:.6f} {vertex[2]:.6f}\n")
403
+
404
+ # Write UV coordinates
405
+ for uv in uv_coords:
406
+ f.write(f"vt {uv[0]:.6f} {uv[1]:.6f}\n")
407
+
408
+ # Write faces
409
+ f.write("usemtl TerrainMaterial_XZ\n")
410
+ for face in faces:
411
+ v1, v2, v3 = face[0] + 1, face[1] + 1, face[2] + 1
412
+ f.write(f"f {v1}/{v1} {v2}/{v2} {v3}/{v3}\n")
413
+
414
+ # 10. Write MTL file.
415
+ with open(mtl_filepath, "w") as f: # pylint: disable=unspecified-encoding
416
+ f.write("# Material with X,Z UV mapping\n")
417
+ f.write("newmtl TerrainMaterial_XZ\n")
418
+ f.write("Ka 1.0 1.0 1.0\n")
419
+ f.write("Kd 1.0 1.0 1.0\n")
420
+ f.write("Ks 0.0 0.0 0.0\n")
421
+ f.write("illum 1\n")
422
+ f.write(f"map_Kd {texture_filename}\n")
423
+
424
+ return obj_filepath, mtl_filepath
425
+
426
+ @staticmethod
427
+ def mesh_to_i3d(
428
+ mesh: trimesh.Trimesh,
429
+ output_dir: str,
430
+ name: str,
431
+ texture_path: str | None = None,
432
+ water_mesh: bool = False,
433
+ ) -> str:
434
+ """Convert a trimesh to i3d format with optional water shader support.
435
+
436
+ Arguments:
437
+ mesh (trimesh.Trimesh): trimesh.Trimesh object to convert
438
+ output_dir (str): Directory to save i3d and copy textures to
439
+ name (str): Base name for output files (e.g., "terrain_mesh")
440
+ texture_path (str | None): Optional path to texture file (will be copied to output_dir)
441
+ water_mesh (bool): If True, adds ocean shader material for water rendering
442
+
443
+ Returns:
444
+ str: Full path to the generated i3d file
445
+ """
446
+
447
+ # Ensure output directory exists
448
+ if not os.path.exists(output_dir):
449
+ os.makedirs(output_dir)
450
+
451
+ # Apply transformations (only for water meshes)
452
+ if water_mesh:
453
+ # 1. Apply rotation fix (90-degree X-axis correction) - water only
454
+ rotation_matrix = trimesh.transformations.rotation_matrix(-np.pi / 2, [1, 0, 0])
455
+ mesh.apply_transform(rotation_matrix)
456
+
457
+ # 2. Center mesh at origin - water only
458
+ vertices = mesh.vertices
459
+ center = vertices.mean(axis=0)
460
+ mesh.vertices = vertices - center
461
+
462
+ # 3. Handle texture copying if provided
463
+ texture_file = None
464
+ if texture_path and os.path.exists(texture_path):
465
+ texture_filename = os.path.basename(texture_path)
466
+ texture_dest = os.path.join(output_dir, texture_filename)
467
+
468
+ # Copy texture if it's not already in output_dir
469
+ if os.path.abspath(texture_path) != os.path.abspath(texture_dest):
470
+ shutil.copy2(texture_path, texture_dest)
471
+
472
+ texture_file = texture_filename
473
+
474
+ # 4. Generate i3d file
475
+ output_path = os.path.join(output_dir, f"{name}.i3d")
476
+ MeshComponent._write_i3d_file(mesh, output_path, name, texture_file, water_mesh)
477
+
478
+ return output_path
479
+
480
+ @staticmethod
481
+ def _write_i3d_file(
482
+ mesh: trimesh.Trimesh,
483
+ output_path: str,
484
+ name: str,
485
+ texture_file: str | None,
486
+ is_water: bool,
487
+ ) -> None:
488
+ """Write the actual i3d XML file.
489
+
490
+ Arguments:
491
+ mesh (trimesh.Trimesh): object containing the geometry
492
+ output_path (str): Full path where to save the i3d file
493
+ name (str): Name for the mesh in i3d file
494
+ texture_file (str | None): Optional texture filename (if copied to output dir)
495
+ is_water (bool): If True, generates water mesh with ocean shader
496
+ """
497
+
498
+ # Root element
499
+ i3d = ET.Element(
500
+ "i3D",
501
+ attrib={
502
+ "name": name,
503
+ "version": "1.6",
504
+ "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance",
505
+ "xsi:noNamespaceSchemaLocation": "http://i3d.giants.ch/schema/i3d-1.6.xsd",
506
+ },
507
+ )
508
+
509
+ # Asset section
510
+ asset = ET.SubElement(i3d, "Asset")
511
+ exp = ET.SubElement(asset, "Export")
512
+ exp.set("program", "maps4fs")
513
+ exp.set("version", "1.0")
514
+ exp.set("date", datetime.now().strftime("%Y-%m-%d"))
515
+
516
+ vertices = mesh.vertices
517
+ faces = mesh.faces
518
+
519
+ has_normals = mesh.vertex_normals is not None and len(mesh.vertex_normals) == len(vertices)
520
+ has_uv = (
521
+ hasattr(mesh.visual, "uv")
522
+ and mesh.visual.uv is not None
523
+ and len(mesh.visual.uv) == len(vertices)
524
+ )
525
+
526
+ # Files section
527
+ files_section = None
528
+ if is_water:
529
+ # Water mesh: add ocean shader
530
+ files_section = ET.SubElement(i3d, "Files")
531
+ shader_file = ET.SubElement(files_section, "File")
532
+ shader_file.set("fileId", "4")
533
+ shader_file.set("filename", "$data/shaders/oceanShader.xml")
534
+ elif texture_file:
535
+ # Terrain mesh: add texture file
536
+ files_section = ET.SubElement(i3d, "Files")
537
+ file_entry = ET.SubElement(files_section, "File")
538
+ file_entry.set("fileId", "1")
539
+ file_entry.set("filename", texture_file)
540
+ file_entry.set("relativePath", "true")
541
+
542
+ # Materials section
543
+ materials_section = ET.SubElement(i3d, "Materials")
544
+ material = ET.SubElement(materials_section, "Material")
545
+
546
+ if is_water:
547
+ # Water material with ocean shader
548
+ material.set("name", "OceanShader")
549
+ material.set("materialId", "1")
550
+ material.set("diffuseColor", "0.8 0.8 0.8 1")
551
+ material.set("specularColor", "0.501961 1 0")
552
+ material.set("customShaderId", "4")
553
+ material.set("customShaderVariation", "simple")
554
+
555
+ # Required for ocean shader
556
+ normalmap = ET.SubElement(material, "Normalmap")
557
+ normalmap.set("fileId", "2")
558
+
559
+ refractionmap = ET.SubElement(material, "Refractionmap")
560
+ refractionmap.set("coeff", "1")
561
+ refractionmap.set("bumpScale", "0.01")
562
+ refractionmap.set("withSSRData", "true")
563
+ else:
564
+ # Standard terrain material
565
+ material.set("name", f"{name}_material")
566
+ material.set("materialId", "1")
567
+ material.set("diffuseColor", "1 1 1 1")
568
+ material.set("specularColor", "0.5 0.5 0.5")
569
+
570
+ if texture_file:
571
+ texture = ET.SubElement(material, "Texture")
572
+ texture.set("fileId", "1")
573
+
574
+ # Shapes section
575
+ shapes = ET.SubElement(i3d, "Shapes")
576
+ shape = ET.SubElement(shapes, "IndexedTriangleSet")
577
+ shape.set("name", name)
578
+ shape.set("shapeId", "1")
579
+
580
+ # Calculate bounding sphere
581
+ if len(vertices) > 0:
582
+ center = vertices.mean(axis=0)
583
+ max_dist = ((vertices - center) ** 2).sum(axis=1).max() ** 0.5
584
+ shape.set("bvCenter", f"{center[0]:.6f} {center[1]:.6f} {center[2]:.6f}")
585
+ shape.set("bvRadius", f"{max_dist:.6f}")
586
+
587
+ # Vertices block
588
+ xml_vertices = ET.SubElement(shape, "Vertices")
589
+ xml_vertices.set("count", str(len(vertices)))
590
+
591
+ if has_normals:
592
+ xml_vertices.set("normal", "true")
593
+ if has_uv:
594
+ xml_vertices.set("uv0", "true")
595
+
596
+ # Write vertex data
597
+ for idx in tqdm(range(len(vertices)), desc="Writing vertices", unit="vertex"):
598
+ v = vertices[idx]
599
+ v_el = ET.SubElement(xml_vertices, "v")
600
+ v_el.set("p", f"{v[0]:.6f} {v[1]:.6f} {v[2]:.6f}")
601
+
602
+ if has_normals:
603
+ n = mesh.vertex_normals[idx]
604
+ v_el.set("n", f"{n[0]:.6f} {n[1]:.6f} {n[2]:.6f}")
605
+
606
+ if has_uv:
607
+ uv = mesh.visual.uv[idx]
608
+ v_el.set("t0", f"{uv[0]:.6f} {uv[1]:.6f}")
609
+
610
+ # Triangles block
611
+ xml_tris = ET.SubElement(shape, "Triangles")
612
+ xml_tris.set("count", str(len(faces)))
613
+ for f in tqdm(faces, desc="Writing triangles", unit="triangle"):
614
+ t = ET.SubElement(xml_tris, "t")
615
+ t.set("vi", f"{f[0]} {f[1]} {f[2]}")
616
+
617
+ # Subsets block
618
+ xml_subs = ET.SubElement(shape, "Subsets")
619
+ xml_subs.set("count", "1")
620
+ subset = ET.SubElement(xml_subs, "Subset")
621
+ subset.set("firstVertex", "0")
622
+ subset.set("numVertices", str(len(vertices)))
623
+ subset.set("firstIndex", "0")
624
+ subset.set("numIndices", str(len(faces) * 3))
625
+
626
+ # Scene section
627
+ scene = ET.SubElement(i3d, "Scene")
628
+
629
+ if is_water:
630
+ # Water: direct shape node
631
+ shape_node = ET.SubElement(scene, "Shape")
632
+ shape_node.set("name", name)
633
+ shape_node.set("shapeId", "1")
634
+ shape_node.set("nodeId", "4")
635
+ shape_node.set("castsShadows", "true")
636
+ shape_node.set("receiveShadows", "true")
637
+ shape_node.set("materialIds", "1")
638
+ else:
639
+ # Terrain: transform group with shape
640
+ transform_group = ET.SubElement(scene, "TransformGroup")
641
+ transform_group.set("name", name)
642
+ transform_group.set("nodeId", "1")
643
+
644
+ shape_node = ET.SubElement(transform_group, "Shape")
645
+ shape_node.set("name", f"{name}_shape")
646
+ shape_node.set("nodeId", "2")
647
+ shape_node.set("shapeId", "1")
648
+ shape_node.set("static", "true")
649
+ shape_node.set("compound", "false")
650
+ shape_node.set("collision", "true")
651
+ shape_node.set("materialIds", "1")
652
+
653
+ # Pretty print and write
654
+ MeshComponent._indent(i3d)
655
+ tree = ET.ElementTree(i3d)
656
+ tree.write(output_path, encoding="iso-8859-1", xml_declaration=True)
657
+
658
+ @staticmethod
659
+ def _indent(elem: ET.Element, level: int = 0) -> None:
660
+ """Pretty print XML formatting. Modifies the element in place.
661
+
662
+ Arguments:
663
+ elem (ET.Element): The XML element to indent
664
+ level (int): Current indentation level
665
+ """
666
+ i = "\n" + level * " "
667
+ if len(elem):
668
+ if not elem.text or not elem.text.strip():
669
+ elem.text = i + " "
670
+ if not elem.tail or not elem.tail.strip():
671
+ elem.tail = i
672
+ for e in elem:
673
+ MeshComponent._indent(e, level + 1)
674
+ if not elem.tail or not elem.tail.strip():
675
+ elem.tail = i
676
+ else:
677
+ if level and (not elem.tail or not elem.tail.strip()):
678
+ elem.tail = i
maps4fs/generator/game.py CHANGED
@@ -42,9 +42,10 @@ class Game:
42
42
  _environment_processing: bool = True
43
43
  _fog_processing: bool = True
44
44
  _dissolve: bool = True
45
+ _mesh_processing: bool = True
45
46
 
46
47
  # Order matters! Some components depend on others.
47
- components = [Texture, Background, GRLE, I3d, Config, Satellite]
48
+ components = [Satellite, Texture, Background, GRLE, I3d, Config]
48
49
 
49
50
  def __init__(self, map_template_path: str | None = None):
50
51
  if map_template_path:
@@ -312,6 +313,14 @@ class Game:
312
313
  bool: True if the dissolve should be applied, False otherwise."""
313
314
  return self._dissolve
314
315
 
316
+ @property
317
+ def mesh_processing(self) -> bool:
318
+ """Returns whether the mesh should be processed.
319
+
320
+ Returns:
321
+ bool: True if the mesh should be processed, False otherwise."""
322
+ return self._mesh_processing
323
+
315
324
 
316
325
  class FS22(Game):
317
326
  """Class used to define the game version FS22."""
@@ -324,6 +333,7 @@ class FS22(Game):
324
333
  _fog_processing = False
325
334
  _plants_processing = False
326
335
  _dissolve = False
336
+ _mesh_processing = False
327
337
 
328
338
  def dem_file_path(self, map_directory: str) -> str:
329
339
  """Returns the path to the DEM file.
maps4fs/generator/map.py CHANGED
@@ -12,7 +12,7 @@ from pydtmdl.base.dtm import DTMProviderSettings
12
12
 
13
13
  import maps4fs.generator.config as mfscfg
14
14
  import maps4fs.generator.utils as mfsutils
15
- from maps4fs.generator.component import Background, Component, Layer, Texture
15
+ from maps4fs.generator.component import Background, Component, Layer, Satellite, Texture
16
16
  from maps4fs.generator.game import Game
17
17
  from maps4fs.generator.settings import GenerationSettings, MainSettings, SharedSettings
18
18
  from maps4fs.generator.statistics import send_advanced_settings, send_main_settings
@@ -150,6 +150,9 @@ class Map:
150
150
  raise RuntimeError(f"Can not unpack map template due to error: {e}") from e
151
151
  # endregion
152
152
 
153
+ self.assets_directory = os.path.join(self.map_directory, "assets")
154
+ os.makedirs(self.assets_directory, exist_ok=True)
155
+
153
156
  self.shared_settings = SharedSettings()
154
157
  self.components: list[Component] = []
155
158
 
@@ -301,6 +304,17 @@ class Map:
301
304
  return None
302
305
  return component
303
306
 
307
+ def get_satellite_component(self) -> Satellite | None:
308
+ """Get satellite component.
309
+
310
+ Returns:
311
+ Satellite | None: Satellite instance or None if not found.
312
+ """
313
+ component = self.get_component("Satellite")
314
+ if not isinstance(component, Satellite):
315
+ return None
316
+ return component
317
+
304
318
  def get_texture_layer(self, by_usage: str | None = None) -> Layer | None:
305
319
  """Get texture layer by usage.
306
320
 
@@ -28,12 +28,19 @@ class Parameters:
28
28
  WATER = "water"
29
29
  FARMYARDS = "farmyards"
30
30
 
31
+ MAXIMUM_BACKGROUND_TEXTURE_SIZE = 4096
32
+
31
33
  PREVIEW_MAXIMUM_SIZE = 2048
32
34
 
33
35
  BACKGROUND_DISTANCE = 2048
34
36
  FULL = "FULL"
35
37
  PREVIEW = "PREVIEW"
36
38
 
39
+ DECIMATED_BACKGROUND = "decimated_background"
40
+ BACKGROUND_TERRAIN = "background_terrain"
41
+
42
+ WATER_RESOURCES = "water_resources"
43
+
37
44
  RESIZE_FACTOR = 8
38
45
 
39
46
  FARMLAND_ID_LIMIT = 254
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maps4fs
3
- Version: 2.8.2
3
+ Version: 2.8.3
4
4
  Summary: Generate map templates for Farming Simulator from real places.
5
5
  Author-email: iwatkot <iwatkot@gmail.com>
6
6
  License: GNU Affero General Public License v3.0
@@ -26,18 +26,22 @@ Requires-Dist: scipy
26
26
  Requires-Dist: pydtmdl
27
27
  Requires-Dist: manifold3d
28
28
  Requires-Dist: fast-simplification
29
+ Requires-Dist: open3d
29
30
  Dynamic: license-file
30
31
 
31
- <p align="center">
32
- <a href="https://github.com/iwatkot/maps4fs">maps4fs</a> •
33
- <a href="https://github.com/iwatkot/maps4fsui">maps4fs UI</a> •
34
- <a href="https://github.com/iwatkot/maps4fsdata">maps4fs Data</a> •
35
- <a href="https://github.com/iwatkot/maps4fsapi">maps4fs API</a> •
36
- <a href="https://github.com/iwatkot/maps4fsstats">maps4fs Stats</a> •
37
- <a href="https://github.com/iwatkot/maps4fsbot">maps4fs Bot</a><br>
38
- <a href="https://github.com/iwatkot/pygmdl">pygmdl</a> •
39
- <a href="https://github.com/iwatkot/pydtmdl">pydtmdl</a>
40
- </p>
32
+ <div align="center" markdown>
33
+
34
+ [![Maps4FS](https://img.shields.io/badge/maps4fs-gray?style=for-the-badge)](https://github.com/iwatkot/maps4fs)
35
+ [![PYDTMDL](https://img.shields.io/badge/pydtmdl-blue?style=for-the-badge)](https://github.com/iwatkot/pydtmdl)
36
+ [![PYGDMDL](https://img.shields.io/badge/pygmdl-teal?style=for-the-badge)](https://github.com/iwatkot/pygmdl)
37
+ [![Maps4FS API](https://img.shields.io/badge/maps4fs-api-green?style=for-the-badge)](https://github.com/iwatkot/maps4fsapi)
38
+ [![Maps4FS UI](https://img.shields.io/badge/maps4fs-ui-blue?style=for-the-badge)](https://github.com/iwatkot/maps4fsui)
39
+ [![Maps4FS Data](https://img.shields.io/badge/maps4fs-data-orange?style=for-the-badge)](https://github.com/iwatkot/maps4fsdata)
40
+ [![Maps4FS Upgrader](https://img.shields.io/badge/maps4fs-upgrader-yellow?style=for-the-badge)](https://github.com/iwatkot/maps4fsupgrader)
41
+ [![Maps4FS Stats](https://img.shields.io/badge/maps4fs-stats-red?style=for-the-badge)](https://github.com/iwatkot/maps4fsstats)
42
+ [![Maps4FS Bot](https://img.shields.io/badge/maps4fs-bot-teal?style=for-the-badge)](https://github.com/iwatkot/maps4fsbot)
43
+
44
+ </div>
41
45
 
42
46
  <div align="center" markdown>
43
47
  <a href="https://discord.gg/Sj5QKKyE42">
@@ -2,14 +2,14 @@ maps4fs/__init__.py,sha256=5ixsCA5vgcIV0OrF9EJBm91Mmc_KfMiDRM-QyifMAvo,386
2
2
  maps4fs/logger.py,sha256=6sem0aFKQqtVjQ_yNu9iGcc-hqzLQUhfxco05K6nqow,763
3
3
  maps4fs/generator/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
4
4
  maps4fs/generator/config.py,sha256=KwYYWM5wrMB_tKn3Fls6WpEurZJxmrBH5AgAXPGHTJE,7122
5
- maps4fs/generator/game.py,sha256=nf6iuYNA5NJc-ir_WOgkw-MdJVgetVHeEtxbWJYt3Vo,14462
6
- maps4fs/generator/map.py,sha256=IC72Mr_04wm5kby4J0m-Bw32UrcE6qn2KwaX9vLEzn4,14036
5
+ maps4fs/generator/game.py,sha256=sNyPNHGd5bEi7-00l5_MVp62KnBmH-OIIabeDwFEepc,14767
6
+ maps4fs/generator/map.py,sha256=ZZRU8x0feGbgeJgxc0D3N-mfiasyFXxj6gbGyl-WRzE,14528
7
7
  maps4fs/generator/qgis.py,sha256=Es8hLuqN_KH8lDfnJE6He2rWYbAKJ3RGPn-o87S6CPI,6116
8
- maps4fs/generator/settings.py,sha256=oDs_bgvqHMJfKGK7uVVkm6MykAL3dVkvm5L3-hvZx2c,13044
8
+ maps4fs/generator/settings.py,sha256=4CDyJdKthVAT5I78_1gcruZVK4TYD1ctklAZBwvFwCM,13226
9
9
  maps4fs/generator/statistics.py,sha256=Dp1-NS-DWv0l0UdmhOoXeQs_N-Hs7svYUnmziSW5Z9I,2098
10
10
  maps4fs/generator/utils.py,sha256=ugdQ8C22NeiZLIlldLoEKCc7ioOefz4W-8qF2eOy9qU,4834
11
11
  maps4fs/generator/component/__init__.py,sha256=s01yVVVi8R2xxNvflu2D6wTd9I_g73AMM2x7vAC7GX4,490
12
- maps4fs/generator/component/background.py,sha256=rUDTPduNCD7KKt_eoVWN4XVSVgjwGSoLNPbZOzpGx7E,34532
12
+ maps4fs/generator/component/background.py,sha256=tFWdYASUUZsJ8QiQrxxba-6aMECD3hvpd0pMm_UdrH8,46151
13
13
  maps4fs/generator/component/config.py,sha256=uL76h9UwyhZKZmbxz0mBmWtEPN6qYay4epTEqqtej60,8601
14
14
  maps4fs/generator/component/dem.py,sha256=FPqcXmFQg5MPaGuy4g5kxzvY1wbhozeCf-aNMCj5eaU,11687
15
15
  maps4fs/generator/component/grle.py,sha256=0PC1K829wjD4y4d9qfIbnU29ebjflIPBbwIZx8FXwc8,27242
@@ -18,12 +18,12 @@ maps4fs/generator/component/layer.py,sha256=bdy1XGOODyPqYUM3b_wEY2H9Piz-AaHsCDec
18
18
  maps4fs/generator/component/satellite.py,sha256=9nKwL8zQ-BB6WFMx2m8zduFn6RaxSNv6Vtpge1-QMYE,5052
19
19
  maps4fs/generator/component/texture.py,sha256=gXZgr73ehT3_qjuIku0j7N7exloywtmmEQKBJ-MDcco,36988
20
20
  maps4fs/generator/component/base/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
21
- maps4fs/generator/component/base/component.py,sha256=lf0V9CLUXMg88Nm2yI3rP5taVYYlNivud0x6kbhBYqA,23312
22
- maps4fs/generator/component/base/component_image.py,sha256=WTGC6v1KuS5sLNCC95Z48nCspvATKKNOuhTNYzTWXr4,8315
23
- maps4fs/generator/component/base/component_mesh.py,sha256=3hC-qDT8Vde6SmRMqs9USAkrF-gL2dDTYW71ATpxUS4,9130
21
+ maps4fs/generator/component/base/component.py,sha256=-7H3donrH19f0_rivNyI3fgLsiZkntXfGywEx4tOnM4,23924
22
+ maps4fs/generator/component/base/component_image.py,sha256=GXFkEFARNRkWkDiGSjvU4WX6f_8s6R1t2ZYqZflv1jk,9626
23
+ maps4fs/generator/component/base/component_mesh.py,sha256=plCLS4O8XsVzAtaRi9lm7a8LnS1dcJ7-mqUpVMvCPAg,24704
24
24
  maps4fs/generator/component/base/component_xml.py,sha256=MT-VhU2dEckLFxAgmxg6V3gnv11di_94Qq6atfpOLdc,5342
25
- maps4fs-2.8.2.dist-info/licenses/LICENSE.md,sha256=Ptw8AkqJ60c4tRts6yuqGP_8B0dxwOGmJsp6YJ8dKqM,34328
26
- maps4fs-2.8.2.dist-info/METADATA,sha256=MeLuVswRau8eG5hivACyRZqqN71C8nbxREvmuoAOYKQ,9425
27
- maps4fs-2.8.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
28
- maps4fs-2.8.2.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
29
- maps4fs-2.8.2.dist-info/RECORD,,
25
+ maps4fs-2.8.3.dist-info/licenses/LICENSE.md,sha256=Ptw8AkqJ60c4tRts6yuqGP_8B0dxwOGmJsp6YJ8dKqM,34328
26
+ maps4fs-2.8.3.dist-info/METADATA,sha256=R4Nh5jeZ3nPZUSZ2KQEG73Sh6mC-MI_jv2_GN7FJw0A,10042
27
+ maps4fs-2.8.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
28
+ maps4fs-2.8.3.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
29
+ maps4fs-2.8.3.dist-info/RECORD,,