subsurface-terra 2025.1.0rc15__py3-none-any.whl → 2025.1.0rc16__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.
Files changed (81) hide show
  1. subsurface/__init__.py +31 -31
  2. subsurface/_version.py +34 -21
  3. subsurface/api/__init__.py +13 -13
  4. subsurface/api/interfaces/__init__.py +3 -3
  5. subsurface/api/interfaces/stream.py +136 -136
  6. subsurface/api/reader/read_wells.py +78 -78
  7. subsurface/core/geological_formats/boreholes/_combine_trajectories.py +117 -117
  8. subsurface/core/geological_formats/boreholes/_map_attrs_to_survey.py +236 -234
  9. subsurface/core/geological_formats/boreholes/_survey_to_unstruct.py +163 -163
  10. subsurface/core/geological_formats/boreholes/boreholes.py +140 -140
  11. subsurface/core/geological_formats/boreholes/collars.py +26 -26
  12. subsurface/core/geological_formats/boreholes/survey.py +86 -86
  13. subsurface/core/geological_formats/fault.py +47 -47
  14. subsurface/core/reader_helpers/reader_unstruct.py +11 -11
  15. subsurface/core/reader_helpers/readers_data.py +130 -130
  16. subsurface/core/reader_helpers/readers_wells.py +13 -13
  17. subsurface/core/structs/__init__.py +3 -3
  18. subsurface/core/structs/base_structures/__init__.py +2 -2
  19. subsurface/core/structs/base_structures/_liquid_earth_mesh.py +121 -121
  20. subsurface/core/structs/base_structures/_unstructured_data_constructor.py +70 -70
  21. subsurface/core/structs/base_structures/base_structures_enum.py +6 -6
  22. subsurface/core/structs/base_structures/structured_data.py +282 -282
  23. subsurface/core/structs/base_structures/unstructured_data.py +319 -319
  24. subsurface/core/structs/structured_elements/octree_mesh.py +10 -10
  25. subsurface/core/structs/structured_elements/structured_grid.py +59 -59
  26. subsurface/core/structs/structured_elements/structured_mesh.py +9 -9
  27. subsurface/core/structs/unstructured_elements/__init__.py +3 -3
  28. subsurface/core/structs/unstructured_elements/line_set.py +72 -72
  29. subsurface/core/structs/unstructured_elements/point_set.py +43 -43
  30. subsurface/core/structs/unstructured_elements/tetrahedron_mesh.py +35 -35
  31. subsurface/core/structs/unstructured_elements/triangular_surface.py +62 -62
  32. subsurface/core/utils/utils_core.py +38 -38
  33. subsurface/modules/reader/__init__.py +13 -13
  34. subsurface/modules/reader/faults/faults.py +80 -80
  35. subsurface/modules/reader/from_binary.py +46 -46
  36. subsurface/modules/reader/mesh/_GOCAD_mesh.py +82 -82
  37. subsurface/modules/reader/mesh/_trimesh_reader.py +447 -447
  38. subsurface/modules/reader/mesh/csv_mesh_reader.py +53 -53
  39. subsurface/modules/reader/mesh/dxf_reader.py +177 -177
  40. subsurface/modules/reader/mesh/glb_reader.py +30 -30
  41. subsurface/modules/reader/mesh/mx_reader.py +232 -232
  42. subsurface/modules/reader/mesh/obj_reader.py +53 -53
  43. subsurface/modules/reader/mesh/omf_mesh_reader.py +43 -43
  44. subsurface/modules/reader/mesh/surface_reader.py +56 -56
  45. subsurface/modules/reader/mesh/surfaces_api.py +41 -41
  46. subsurface/modules/reader/profiles/__init__.py +3 -3
  47. subsurface/modules/reader/profiles/profiles_core.py +197 -197
  48. subsurface/modules/reader/read_netcdf.py +38 -38
  49. subsurface/modules/reader/topography/__init__.py +7 -7
  50. subsurface/modules/reader/topography/topo_core.py +100 -100
  51. subsurface/modules/reader/volume/read_grav3d.py +478 -428
  52. subsurface/modules/reader/volume/read_volume.py +327 -230
  53. subsurface/modules/reader/volume/segy_reader.py +105 -105
  54. subsurface/modules/reader/volume/seismic.py +173 -173
  55. subsurface/modules/reader/volume/volume_utils.py +43 -43
  56. subsurface/modules/reader/wells/DEP/__init__.py +43 -43
  57. subsurface/modules/reader/wells/DEP/_well_files_reader.py +167 -167
  58. subsurface/modules/reader/wells/DEP/_wells_api.py +61 -61
  59. subsurface/modules/reader/wells/DEP/_welly_reader.py +180 -180
  60. subsurface/modules/reader/wells/DEP/pandas_to_welly.py +212 -212
  61. subsurface/modules/reader/wells/_read_to_df.py +57 -57
  62. subsurface/modules/reader/wells/read_borehole_interface.py +148 -148
  63. subsurface/modules/reader/wells/wells_utils.py +68 -68
  64. subsurface/modules/tools/mocking_aux.py +104 -104
  65. subsurface/modules/visualization/__init__.py +2 -2
  66. subsurface/modules/visualization/to_pyvista.py +320 -320
  67. subsurface/modules/writer/to_binary.py +12 -12
  68. subsurface/modules/writer/to_rex/common.py +78 -78
  69. subsurface/modules/writer/to_rex/data_struct.py +74 -74
  70. subsurface/modules/writer/to_rex/gempy_to_rexfile.py +791 -791
  71. subsurface/modules/writer/to_rex/material_encoder.py +44 -44
  72. subsurface/modules/writer/to_rex/mesh_encoder.py +152 -152
  73. subsurface/modules/writer/to_rex/to_rex.py +115 -115
  74. subsurface/modules/writer/to_rex/utils.py +15 -15
  75. subsurface/optional_requirements.py +116 -116
  76. {subsurface_terra-2025.1.0rc15.dist-info → subsurface_terra-2025.1.0rc16.dist-info}/METADATA +194 -194
  77. subsurface_terra-2025.1.0rc16.dist-info/RECORD +98 -0
  78. {subsurface_terra-2025.1.0rc15.dist-info → subsurface_terra-2025.1.0rc16.dist-info}/WHEEL +1 -1
  79. {subsurface_terra-2025.1.0rc15.dist-info → subsurface_terra-2025.1.0rc16.dist-info}/licenses/LICENSE +203 -203
  80. subsurface_terra-2025.1.0rc15.dist-info/RECORD +0 -98
  81. {subsurface_terra-2025.1.0rc15.dist-info → subsurface_terra-2025.1.0rc16.dist-info}/top_level.txt +0 -0
@@ -1,447 +1,447 @@
1
- import enum
2
- from typing import Union, TextIO, Optional
3
- import io
4
- import os
5
-
6
- import numpy as np
7
- from ....core.structs import UnstructuredData
8
- from .... import optional_requirements
9
- from ....core.structs import TriSurf, StructuredData
10
-
11
-
12
- class TriMeshTransformations(enum.Flag):
13
- UP_Z = 2**1
14
- UP_Y = 2**2
15
- FORWARD_MINUS_Z = 2**3
16
- FORWARD_PLUS_Z = 2**4
17
- RIGHT_HANDED_Z_UP_Y_REVERSED = UP_Y | FORWARD_MINUS_Z
18
- RIGHT_HANDED_Z_UP = UP_Y | FORWARD_PLUS_Z
19
- ORIGINAL = UP_Z | FORWARD_MINUS_Z
20
-
21
-
22
- def load_with_trimesh(path_to_file_or_buffer, file_type: Optional[str] = None,
23
- coordinate_system: TriMeshTransformations = TriMeshTransformations.RIGHT_HANDED_Z_UP, *, plot=False):
24
- """
25
- Load a mesh with trimesh and convert to the specified coordinate system.
26
-
27
- """
28
- trimesh = optional_requirements.require_trimesh()
29
- scene_or_mesh = LoadWithTrimesh.load_with_trimesh(path_to_file_or_buffer, file_type, plot)
30
-
31
- match coordinate_system:
32
- case TriMeshTransformations.ORIGINAL:
33
- return scene_or_mesh
34
- # * Forward -Z up Y
35
- case TriMeshTransformations.RIGHT_HANDED_Z_UP:
36
- # Transform from Y-up (modeling software) to Z-up (scientific)
37
- # This rotates the model so that:
38
- # Old Y axis → New Z axis (pointing up)
39
- # Old Z axis → New -Y axis
40
- # Old X axis → Remains as X axis
41
- transform = np.array([
42
- [1, 0, 0, 0],
43
- [0, 0, -1, 0],
44
- [0, 1, 0, 0],
45
- [0, 0, 0, 1]
46
- ])
47
- case TriMeshTransformations.RIGHT_HANDED_Z_UP_Y_REVERSED:
48
- # * Forward Z Up Y
49
- transform=np.array([
50
- [-1, 0, 0, 0],
51
- [0, 0, 1, 0],
52
- [0, 1, 0, 0],
53
- [0, 0, 0, 1],
54
- ])
55
- # Apply the coordinate transformation
56
- # TODO: Add all the options of blender
57
- case _:
58
- raise ValueError(f"Invalid coordinate system: {coordinate_system}")
59
-
60
- if isinstance(scene_or_mesh, trimesh.Scene):
61
- for geometry in scene_or_mesh.geometry.values():
62
- geometry.apply_transform(transform)
63
- else:
64
- scene_or_mesh.apply_transform(transform)
65
- return scene_or_mesh
66
-
67
-
68
- def trimesh_to_unstruct(scene_or_mesh: Union["trimesh.Trimesh", "trimesh.Scene"]) -> TriSurf:
69
- return TrimeshToSubsurface.trimesh_to_unstruct(scene_or_mesh)
70
-
71
-
72
- class LoadWithTrimesh:
73
- @classmethod
74
- def load_with_trimesh(cls, path_to_file_or_buffer, file_type: Optional[str] = None, plot=False):
75
- trimesh = optional_requirements.require_trimesh()
76
- # Load the OBJ with Trimesh using the specified options
77
- scene_or_mesh = trimesh.load(
78
- file_obj=path_to_file_or_buffer,
79
- file_type=file_type,
80
- force="mesh"
81
- )
82
- # Process single mesh vs. scene
83
- if isinstance(scene_or_mesh, trimesh.Scene):
84
- print("Loaded a Scene with multiple geometries.")
85
- cls._process_scene(scene_or_mesh)
86
- if plot:
87
- scene_or_mesh.show()
88
- else:
89
- print("Loaded a single Trimesh object.")
90
- print(f" - Vertices: {len(scene_or_mesh.vertices)}")
91
- print(f" - Faces: {len(scene_or_mesh.faces)}")
92
- cls.handle_material_info(scene_or_mesh)
93
- if plot:
94
- scene_or_mesh.show()
95
-
96
- return scene_or_mesh
97
-
98
- @classmethod
99
- def handle_material_info(cls, geometry):
100
- """
101
- Handle and print material information for a single geometry,
102
- explicitly injecting the PIL image if provided.
103
- """
104
- if geometry.visual and hasattr(geometry.visual, 'material'):
105
- material = geometry.visual.material
106
-
107
- print("Trimesh material:", material)
108
-
109
- # If there's already an image reference in the material, let the user know
110
- if hasattr(material, 'image') and material.image is not None:
111
- print(" -> Material already has an image:", material.image)
112
-
113
- if geometry.visual.uv is None:
114
- raise ValueError("Geometry does not have UV coordinates for texture mapping, despite having a material."
115
- "This can also happen if the geometry is given in quads instead of triangles.")
116
- else:
117
- print("No material found or no 'material' attribute on this geometry.")
118
-
119
- @classmethod
120
- def _process_scene(cls, scene):
121
- """Process a scene with multiple geometries."""
122
- geometries = scene.geometry
123
- assert len(geometries) > 0, "No geometries found in the scene."
124
-
125
- print(f"Loaded a Scene with {len(scene.geometry)} geometry object(s).")
126
- for geom_name, geom in geometries.items():
127
- print(f" Submesh: {geom_name}")
128
- print(f" - Vertices: {len(geom.vertices)}")
129
- print(f" - Faces: {len(geom.faces)}")
130
-
131
- print(f"Geometry '{geom_name}':")
132
- cls.handle_material_info(geom)
133
-
134
-
135
- class TrimeshToSubsurface:
136
- @classmethod
137
- def trimesh_to_unstruct(cls, scene_or_mesh: Union["trimesh.Trimesh", "trimesh.Scene"]) -> TriSurf:
138
- """
139
- Convert a Trimesh or Scene object to a subsurface TriSurf object.
140
-
141
- This function takes either a `trimesh.Trimesh` object or a `trimesh.Scene`
142
- object and converts it to a `subsurface.TriSurf` object. If the input is
143
- a scene containing multiple geometries, it processes all geometries and
144
- combines them into a single TriSurf object. If the input is a single
145
- Trimesh object, it directly converts it to a TriSurf object. An error
146
- is raised if the input is neither a `trimesh.Trimesh` nor a `trimesh.Scene`
147
- object.
148
-
149
- Parameters:
150
- scene_or_mesh (Union[trimesh.Trimesh, trimesh.Scene]):
151
- Input geometry data, either as a Trimesh object representing
152
- a single mesh or a Scene object containing multiple geometries.
153
-
154
- Note:
155
- ! Multimesh with multiple materials will read the uvs but not the textures since in that case is better
156
- ! to read directly the multiple images (compressed) whenever the user wants to work with them.
157
-
158
- Returns:
159
- subsurface.TriSurf: Converted subsurface representation of the
160
- provided geometry data.
161
-
162
- Raises:
163
- ValueError: If the input is neither a `trimesh.Trimesh` object nor
164
- a `trimesh.Scene` object.
165
- """
166
- trimesh = optional_requirements.require_trimesh()
167
- if isinstance(scene_or_mesh, trimesh.Scene):
168
- # Process scene with multiple geometries
169
- ts = cls._trisurf_from_scene(scene_or_mesh, trimesh)
170
-
171
- elif isinstance(scene_or_mesh, trimesh.Trimesh):
172
- ts = cls._trisurf_from_trimesh(scene_or_mesh)
173
-
174
-
175
- else:
176
- raise ValueError("Input must be a Trimesh object or a Scene with multiple geometries.")
177
-
178
- return ts
179
-
180
- @classmethod
181
- def _trisurf_from_trimesh(cls, scene_or_mesh):
182
- # Process single mesh
183
- tri = scene_or_mesh
184
- pandas = optional_requirements.require_pandas()
185
- frame = pandas.DataFrame(tri.face_attributes)
186
- # Check frame has a valid shape for cells_attr if not make None
187
- if frame.shape[0] != tri.faces.shape[0]:
188
- frame = None
189
- # Get UV coordinates if they exist
190
- vertex_attr = None
191
- if hasattr(tri.visual, 'uv') and tri.visual.uv is not None:
192
- vertex_attr = pandas.DataFrame(
193
- tri.visual.uv,
194
- columns=['u', 'v']
195
- )
196
- unstruct = UnstructuredData.from_array(
197
- np.array(tri.vertices),
198
- np.array(tri.faces),
199
- cells_attr=frame,
200
- vertex_attr=vertex_attr,
201
- xarray_attributes={
202
- "bounds": tri.bounds.tolist(),
203
- },
204
- )
205
-
206
- texture = cls._extract_texture_from_material(tri)
207
-
208
- ts = TriSurf(
209
- mesh=unstruct,
210
- texture=texture,
211
- )
212
- return ts
213
-
214
- @classmethod
215
- def _trisurf_from_scene(cls, scene_or_mesh: 'Scene', trimesh: 'trimesh') -> TriSurf:
216
- pandas = optional_requirements.require_pandas()
217
- geometries = scene_or_mesh.geometry
218
- assert len(geometries) > 0, "No geometries found in the scene."
219
- all_vertex = []
220
- all_cells = []
221
- cell_attr = []
222
- all_vertex_attr = []
223
- _last_cell = 0
224
- texture = None
225
- for i, (geom_name, geom) in enumerate(geometries.items()):
226
- geom: trimesh.Trimesh
227
- LoadWithTrimesh.handle_material_info(geom)
228
-
229
- # Append vertices
230
- all_vertex.append(np.array(geom.vertices))
231
-
232
- # Adjust cell indices and append
233
- cells = np.array(geom.faces)
234
- if len(all_cells) > 0:
235
- cells = cells + _last_cell
236
- all_cells.append(cells)
237
-
238
- # Create attribute array for this geometry
239
- cell_attr.append(np.ones(len(cells)) * i)
240
-
241
- _last_cell = cells.max() + 1
242
-
243
- # Get UV coordinates if they exist
244
- if hasattr(geom.visual, 'uv') and geom.visual.uv is not None:
245
- vertex_attr = pandas.DataFrame(
246
- geom.visual.uv,
247
- columns=['u', 'v']
248
- )
249
- all_vertex_attr.append(vertex_attr)
250
-
251
- # Extract texture from material if it is only one geometry
252
- if len(geometries) == 1:
253
- texture = cls._extract_texture_from_material(geom)
254
-
255
- # Create the combined UnstructuredData
256
- unstruct = UnstructuredData.from_array(
257
- vertex=np.vstack(all_vertex),
258
- cells=np.vstack(all_cells),
259
- vertex_attr=pandas.concat(all_vertex_attr, ignore_index=True) if len(all_vertex_attr) > 0 else None,
260
- cells_attr=pandas.DataFrame(np.hstack(cell_attr), columns=["Geometry id"]),
261
- xarray_attributes={
262
- "bounds": scene_or_mesh.bounds.tolist(),
263
- },
264
- )
265
-
266
- # If there is a texture
267
- ts = TriSurf(
268
- mesh=unstruct,
269
- texture=texture,
270
- )
271
-
272
- return ts
273
-
274
- @classmethod
275
- def _extract_texture_from_material(cls, geom):
276
- from PIL.JpegImagePlugin import JpegImageFile
277
- from PIL.PngImagePlugin import PngImageFile
278
- import trimesh
279
-
280
- if geom.visual is None or getattr(geom.visual, 'material', None) is None:
281
- return None
282
-
283
- array = np.empty(0)
284
- if isinstance(geom.visual.material, trimesh.visual.material.SimpleMaterial):
285
- image: JpegImageFile = geom.visual.material.image
286
- if image is None:
287
- return None
288
- array = np.array(image)
289
- elif isinstance(geom.visual.material, trimesh.visual.material.PBRMaterial):
290
- image: PngImageFile = geom.visual.material.baseColorTexture
291
- array = np.array(image.convert('RGBA'))
292
-
293
- if image is None:
294
- return None
295
- else:
296
- raise ValueError(f"Unsupported material type: {type(geom.visual.material)}")
297
-
298
- # Asser that image has 3 channels assert array.shape[2] == 3 from PIL.PngImagePlugin import PngImageFile
299
- assert array.shape[2] == 3 or array.shape[2] == 4
300
- texture = StructuredData.from_numpy(array)
301
- return texture
302
-
303
- @classmethod
304
- def _validate_texture_path(cls, texture_path):
305
- """Validate the texture file path."""
306
- if texture_path and not texture_path.lower().endswith(('.png', '.jpg', '.jpeg')):
307
- raise ValueError("Texture path must be a PNG or JPEG file")
308
-
309
-
310
- class TriMeshReaderFromBlob:
311
- @classmethod
312
- def OBJ_stream_to_trisurf(cls, obj_stream: TextIO, mtl_stream: list[TextIO],
313
- texture_stream: list[io.BytesIO], coord_system: TriMeshTransformations) -> TriSurf:
314
- """
315
- Load an OBJ file from a stream and convert it to a TriSurf object.
316
-
317
- Parameters:
318
- obj_stream: TextIO containing the OBJ file data (text format)
319
- mtl_stream: TextIO containing the MTL file data (text format)
320
- texture_stream: BytesIO containing the texture file data (binary format)
321
-
322
- Returns:
323
- TriSurf: The loaded mesh with textures if available
324
- """
325
- trimesh = optional_requirements.require_trimesh()
326
- import tempfile
327
-
328
- path_in = "file.obj"
329
-
330
- # Create a temporary directory to store associated files
331
- with tempfile.TemporaryDirectory() as temp_dir:
332
- # Write the OBJ content to a temp file
333
- obj_path = os.path.join(temp_dir, os.path.basename(path_in))
334
- with open(obj_path, 'w') as f: # Use text mode 'w' for text files
335
- obj_stream.seek(0)
336
- f.write(obj_stream.read())
337
- obj_stream.seek(0)
338
-
339
- if mtl_stream is not None:
340
- cls.write_material_files(
341
- mtl_streams=mtl_stream,
342
- obj_stream=obj_stream,
343
- temp_dir=temp_dir,
344
- texture_streams=texture_stream
345
- )
346
-
347
- # Now load the OBJ with all associated files available
348
- scene_or_mesh = load_with_trimesh(
349
- path_to_file_or_buffer=obj_path,
350
- file_type="obj",
351
- coordinate_system=coord_system
352
- )
353
-
354
- # Convert to a TriSurf object
355
- tri_surf = TrimeshToSubsurface.trimesh_to_unstruct(scene_or_mesh)
356
-
357
- return tri_surf
358
-
359
- @classmethod
360
- def write_material_files(cls, mtl_streams: list[TextIO], obj_stream: TextIO, temp_dir, texture_streams: list[io.BytesIO]):
361
- # Extract mtl references from the OBJ file
362
- mtl_files = cls._extract_mtl_references(obj_stream)
363
- # Download and save MTL files
364
- for e, mtl_file in enumerate(mtl_files):
365
- mtl_path = f"{temp_dir}/{mtl_file}" if temp_dir else mtl_file
366
- mtl_stream = mtl_streams[e] if mtl_streams else None
367
- try:
368
- # Save the MTL file to temp directory
369
- mtl_temp_path = os.path.join(temp_dir, mtl_file)
370
- with open(mtl_temp_path, 'w') as f: # Use text mode 'w' for text files
371
- mtl_stream.seek(0)
372
- f.write(mtl_stream.read())
373
-
374
- # Extract texture references from MTL
375
- mtl_stream.seek(0)
376
- texture_files = cls._extract_texture_references(mtl_stream)
377
-
378
- if texture_streams is None:
379
- continue
380
-
381
- # Download texture files
382
- for ee, texture_file in enumerate(texture_files):
383
- texture_path = f"{temp_dir}/{texture_file}" if temp_dir else texture_file
384
- texture_stream = texture_streams[ee] if texture_streams else None
385
- try:
386
- # Save the texture file to temp directory
387
- with open(os.path.join(temp_dir, texture_file), 'wb') as f: # Binary mode for textures
388
- texture_stream.seek(0)
389
- f.write(texture_stream.read())
390
- except Exception as e:
391
- print(f"Failed to load texture {texture_file}: {e}")
392
- except Exception as e:
393
- print(f"Failed to load MTL file {mtl_file}: {e}")
394
-
395
- @classmethod
396
- def _extract_mtl_references(cls, obj_stream):
397
- """Extract MTL file references from an OBJ file."""
398
- obj_stream.seek(0)
399
- mtl_files = []
400
-
401
- # TextIO stream already contains decoded text, so no need to decode
402
- obj_text = obj_stream.read()
403
- obj_stream.seek(0)
404
-
405
- for line in obj_text.splitlines():
406
- if line.startswith('mtllib '):
407
- mtl_name = line.split(None, 1)[1].strip()
408
- mtl_files.append(mtl_name)
409
-
410
- return mtl_files
411
-
412
- @classmethod
413
- def _extract_texture_references(cls, mtl_stream):
414
- """
415
- Extract texture file references from an MTL file.
416
- Works with both TextIO and BytesIO streams.
417
-
418
- Parameters:
419
- mtl_stream: TextIO or BytesIO containing the MTL file data
420
-
421
- Returns:
422
- list[str]: List of texture file names referenced in the MTL
423
- """
424
- mtl_stream.seek(0)
425
- texture_files = []
426
-
427
- # Handle both TextIO and BytesIO
428
- if isinstance(mtl_stream, io.TextIOWrapper):
429
- # TextIO stream already contains decoded text
430
- mtl_text = mtl_stream.read()
431
- else:
432
- # BytesIO stream needs to be decoded
433
- mtl_text = mtl_stream.read().decode('utf-8', errors='replace')
434
-
435
- mtl_stream.seek(0)
436
-
437
- for line in mtl_text.splitlines():
438
- # Check for texture map definitions
439
- for prefix in ['map_Kd ', 'map_Ka ', 'map_Ks ', 'map_Bump ', 'map_d ']:
440
- if line.startswith(prefix):
441
- parts = line.split(None, 1)
442
- if len(parts) > 1:
443
- texture_name = parts[1].strip()
444
- texture_files.append(texture_name)
445
- break
446
-
447
- return texture_files
1
+ import enum
2
+ from typing import Union, TextIO, Optional
3
+ import io
4
+ import os
5
+
6
+ import numpy as np
7
+ from ....core.structs import UnstructuredData
8
+ from .... import optional_requirements
9
+ from ....core.structs import TriSurf, StructuredData
10
+
11
+
12
+ class TriMeshTransformations(enum.Flag):
13
+ UP_Z = 2**1
14
+ UP_Y = 2**2
15
+ FORWARD_MINUS_Z = 2**3
16
+ FORWARD_PLUS_Z = 2**4
17
+ RIGHT_HANDED_Z_UP_Y_REVERSED = UP_Y | FORWARD_MINUS_Z
18
+ RIGHT_HANDED_Z_UP = UP_Y | FORWARD_PLUS_Z
19
+ ORIGINAL = UP_Z | FORWARD_MINUS_Z
20
+
21
+
22
+ def load_with_trimesh(path_to_file_or_buffer, file_type: Optional[str] = None,
23
+ coordinate_system: TriMeshTransformations = TriMeshTransformations.RIGHT_HANDED_Z_UP, *, plot=False):
24
+ """
25
+ Load a mesh with trimesh and convert to the specified coordinate system.
26
+
27
+ """
28
+ trimesh = optional_requirements.require_trimesh()
29
+ scene_or_mesh = LoadWithTrimesh.load_with_trimesh(path_to_file_or_buffer, file_type, plot)
30
+
31
+ match coordinate_system:
32
+ case TriMeshTransformations.ORIGINAL:
33
+ return scene_or_mesh
34
+ # * Forward -Z up Y
35
+ case TriMeshTransformations.RIGHT_HANDED_Z_UP:
36
+ # Transform from Y-up (modeling software) to Z-up (scientific)
37
+ # This rotates the model so that:
38
+ # Old Y axis → New Z axis (pointing up)
39
+ # Old Z axis → New -Y axis
40
+ # Old X axis → Remains as X axis
41
+ transform = np.array([
42
+ [1, 0, 0, 0],
43
+ [0, 0, -1, 0],
44
+ [0, 1, 0, 0],
45
+ [0, 0, 0, 1]
46
+ ])
47
+ case TriMeshTransformations.RIGHT_HANDED_Z_UP_Y_REVERSED:
48
+ # * Forward Z Up Y
49
+ transform=np.array([
50
+ [-1, 0, 0, 0],
51
+ [0, 0, 1, 0],
52
+ [0, 1, 0, 0],
53
+ [0, 0, 0, 1],
54
+ ])
55
+ # Apply the coordinate transformation
56
+ # TODO: Add all the options of blender
57
+ case _:
58
+ raise ValueError(f"Invalid coordinate system: {coordinate_system}")
59
+
60
+ if isinstance(scene_or_mesh, trimesh.Scene):
61
+ for geometry in scene_or_mesh.geometry.values():
62
+ geometry.apply_transform(transform)
63
+ else:
64
+ scene_or_mesh.apply_transform(transform)
65
+ return scene_or_mesh
66
+
67
+
68
+ def trimesh_to_unstruct(scene_or_mesh: Union["trimesh.Trimesh", "trimesh.Scene"]) -> TriSurf:
69
+ return TrimeshToSubsurface.trimesh_to_unstruct(scene_or_mesh)
70
+
71
+
72
+ class LoadWithTrimesh:
73
+ @classmethod
74
+ def load_with_trimesh(cls, path_to_file_or_buffer, file_type: Optional[str] = None, plot=False):
75
+ trimesh = optional_requirements.require_trimesh()
76
+ # Load the OBJ with Trimesh using the specified options
77
+ scene_or_mesh = trimesh.load(
78
+ file_obj=path_to_file_or_buffer,
79
+ file_type=file_type,
80
+ force="mesh"
81
+ )
82
+ # Process single mesh vs. scene
83
+ if isinstance(scene_or_mesh, trimesh.Scene):
84
+ print("Loaded a Scene with multiple geometries.")
85
+ cls._process_scene(scene_or_mesh)
86
+ if plot:
87
+ scene_or_mesh.show()
88
+ else:
89
+ print("Loaded a single Trimesh object.")
90
+ print(f" - Vertices: {len(scene_or_mesh.vertices)}")
91
+ print(f" - Faces: {len(scene_or_mesh.faces)}")
92
+ cls.handle_material_info(scene_or_mesh)
93
+ if plot:
94
+ scene_or_mesh.show()
95
+
96
+ return scene_or_mesh
97
+
98
+ @classmethod
99
+ def handle_material_info(cls, geometry):
100
+ """
101
+ Handle and print material information for a single geometry,
102
+ explicitly injecting the PIL image if provided.
103
+ """
104
+ if geometry.visual and hasattr(geometry.visual, 'material'):
105
+ material = geometry.visual.material
106
+
107
+ print("Trimesh material:", material)
108
+
109
+ # If there's already an image reference in the material, let the user know
110
+ if hasattr(material, 'image') and material.image is not None:
111
+ print(" -> Material already has an image:", material.image)
112
+
113
+ if geometry.visual.uv is None:
114
+ raise ValueError("Geometry does not have UV coordinates for texture mapping, despite having a material."
115
+ "This can also happen if the geometry is given in quads instead of triangles.")
116
+ else:
117
+ print("No material found or no 'material' attribute on this geometry.")
118
+
119
+ @classmethod
120
+ def _process_scene(cls, scene):
121
+ """Process a scene with multiple geometries."""
122
+ geometries = scene.geometry
123
+ assert len(geometries) > 0, "No geometries found in the scene."
124
+
125
+ print(f"Loaded a Scene with {len(scene.geometry)} geometry object(s).")
126
+ for geom_name, geom in geometries.items():
127
+ print(f" Submesh: {geom_name}")
128
+ print(f" - Vertices: {len(geom.vertices)}")
129
+ print(f" - Faces: {len(geom.faces)}")
130
+
131
+ print(f"Geometry '{geom_name}':")
132
+ cls.handle_material_info(geom)
133
+
134
+
135
+ class TrimeshToSubsurface:
136
+ @classmethod
137
+ def trimesh_to_unstruct(cls, scene_or_mesh: Union["trimesh.Trimesh", "trimesh.Scene"]) -> TriSurf:
138
+ """
139
+ Convert a Trimesh or Scene object to a subsurface TriSurf object.
140
+
141
+ This function takes either a `trimesh.Trimesh` object or a `trimesh.Scene`
142
+ object and converts it to a `subsurface.TriSurf` object. If the input is
143
+ a scene containing multiple geometries, it processes all geometries and
144
+ combines them into a single TriSurf object. If the input is a single
145
+ Trimesh object, it directly converts it to a TriSurf object. An error
146
+ is raised if the input is neither a `trimesh.Trimesh` nor a `trimesh.Scene`
147
+ object.
148
+
149
+ Parameters:
150
+ scene_or_mesh (Union[trimesh.Trimesh, trimesh.Scene]):
151
+ Input geometry data, either as a Trimesh object representing
152
+ a single mesh or a Scene object containing multiple geometries.
153
+
154
+ Note:
155
+ ! Multimesh with multiple materials will read the uvs but not the textures since in that case is better
156
+ ! to read directly the multiple images (compressed) whenever the user wants to work with them.
157
+
158
+ Returns:
159
+ subsurface.TriSurf: Converted subsurface representation of the
160
+ provided geometry data.
161
+
162
+ Raises:
163
+ ValueError: If the input is neither a `trimesh.Trimesh` object nor
164
+ a `trimesh.Scene` object.
165
+ """
166
+ trimesh = optional_requirements.require_trimesh()
167
+ if isinstance(scene_or_mesh, trimesh.Scene):
168
+ # Process scene with multiple geometries
169
+ ts = cls._trisurf_from_scene(scene_or_mesh, trimesh)
170
+
171
+ elif isinstance(scene_or_mesh, trimesh.Trimesh):
172
+ ts = cls._trisurf_from_trimesh(scene_or_mesh)
173
+
174
+
175
+ else:
176
+ raise ValueError("Input must be a Trimesh object or a Scene with multiple geometries.")
177
+
178
+ return ts
179
+
180
+ @classmethod
181
+ def _trisurf_from_trimesh(cls, scene_or_mesh):
182
+ # Process single mesh
183
+ tri = scene_or_mesh
184
+ pandas = optional_requirements.require_pandas()
185
+ frame = pandas.DataFrame(tri.face_attributes)
186
+ # Check frame has a valid shape for cells_attr if not make None
187
+ if frame.shape[0] != tri.faces.shape[0]:
188
+ frame = None
189
+ # Get UV coordinates if they exist
190
+ vertex_attr = None
191
+ if hasattr(tri.visual, 'uv') and tri.visual.uv is not None:
192
+ vertex_attr = pandas.DataFrame(
193
+ tri.visual.uv,
194
+ columns=['u', 'v']
195
+ )
196
+ unstruct = UnstructuredData.from_array(
197
+ np.array(tri.vertices),
198
+ np.array(tri.faces),
199
+ cells_attr=frame,
200
+ vertex_attr=vertex_attr,
201
+ xarray_attributes={
202
+ "bounds": tri.bounds.tolist(),
203
+ },
204
+ )
205
+
206
+ texture = cls._extract_texture_from_material(tri)
207
+
208
+ ts = TriSurf(
209
+ mesh=unstruct,
210
+ texture=texture,
211
+ )
212
+ return ts
213
+
214
+ @classmethod
215
+ def _trisurf_from_scene(cls, scene_or_mesh: 'Scene', trimesh: 'trimesh') -> TriSurf:
216
+ pandas = optional_requirements.require_pandas()
217
+ geometries = scene_or_mesh.geometry
218
+ assert len(geometries) > 0, "No geometries found in the scene."
219
+ all_vertex = []
220
+ all_cells = []
221
+ cell_attr = []
222
+ all_vertex_attr = []
223
+ _last_cell = 0
224
+ texture = None
225
+ for i, (geom_name, geom) in enumerate(geometries.items()):
226
+ geom: trimesh.Trimesh
227
+ LoadWithTrimesh.handle_material_info(geom)
228
+
229
+ # Append vertices
230
+ all_vertex.append(np.array(geom.vertices))
231
+
232
+ # Adjust cell indices and append
233
+ cells = np.array(geom.faces)
234
+ if len(all_cells) > 0:
235
+ cells = cells + _last_cell
236
+ all_cells.append(cells)
237
+
238
+ # Create attribute array for this geometry
239
+ cell_attr.append(np.ones(len(cells)) * i)
240
+
241
+ _last_cell = cells.max() + 1
242
+
243
+ # Get UV coordinates if they exist
244
+ if hasattr(geom.visual, 'uv') and geom.visual.uv is not None:
245
+ vertex_attr = pandas.DataFrame(
246
+ geom.visual.uv,
247
+ columns=['u', 'v']
248
+ )
249
+ all_vertex_attr.append(vertex_attr)
250
+
251
+ # Extract texture from material if it is only one geometry
252
+ if len(geometries) == 1:
253
+ texture = cls._extract_texture_from_material(geom)
254
+
255
+ # Create the combined UnstructuredData
256
+ unstruct = UnstructuredData.from_array(
257
+ vertex=np.vstack(all_vertex),
258
+ cells=np.vstack(all_cells),
259
+ vertex_attr=pandas.concat(all_vertex_attr, ignore_index=True) if len(all_vertex_attr) > 0 else None,
260
+ cells_attr=pandas.DataFrame(np.hstack(cell_attr), columns=["Geometry id"]),
261
+ xarray_attributes={
262
+ "bounds": scene_or_mesh.bounds.tolist(),
263
+ },
264
+ )
265
+
266
+ # If there is a texture
267
+ ts = TriSurf(
268
+ mesh=unstruct,
269
+ texture=texture,
270
+ )
271
+
272
+ return ts
273
+
274
+ @classmethod
275
+ def _extract_texture_from_material(cls, geom):
276
+ from PIL.JpegImagePlugin import JpegImageFile
277
+ from PIL.PngImagePlugin import PngImageFile
278
+ import trimesh
279
+
280
+ if geom.visual is None or getattr(geom.visual, 'material', None) is None:
281
+ return None
282
+
283
+ array = np.empty(0)
284
+ if isinstance(geom.visual.material, trimesh.visual.material.SimpleMaterial):
285
+ image: JpegImageFile = geom.visual.material.image
286
+ if image is None:
287
+ return None
288
+ array = np.array(image)
289
+ elif isinstance(geom.visual.material, trimesh.visual.material.PBRMaterial):
290
+ image: PngImageFile = geom.visual.material.baseColorTexture
291
+ array = np.array(image.convert('RGBA'))
292
+
293
+ if image is None:
294
+ return None
295
+ else:
296
+ raise ValueError(f"Unsupported material type: {type(geom.visual.material)}")
297
+
298
+ # Asser that image has 3 channels assert array.shape[2] == 3 from PIL.PngImagePlugin import PngImageFile
299
+ assert array.shape[2] == 3 or array.shape[2] == 4
300
+ texture = StructuredData.from_numpy(array)
301
+ return texture
302
+
303
+ @classmethod
304
+ def _validate_texture_path(cls, texture_path):
305
+ """Validate the texture file path."""
306
+ if texture_path and not texture_path.lower().endswith(('.png', '.jpg', '.jpeg')):
307
+ raise ValueError("Texture path must be a PNG or JPEG file")
308
+
309
+
310
+ class TriMeshReaderFromBlob:
311
+ @classmethod
312
+ def OBJ_stream_to_trisurf(cls, obj_stream: TextIO, mtl_stream: list[TextIO],
313
+ texture_stream: list[io.BytesIO], coord_system: TriMeshTransformations) -> TriSurf:
314
+ """
315
+ Load an OBJ file from a stream and convert it to a TriSurf object.
316
+
317
+ Parameters:
318
+ obj_stream: TextIO containing the OBJ file data (text format)
319
+ mtl_stream: TextIO containing the MTL file data (text format)
320
+ texture_stream: BytesIO containing the texture file data (binary format)
321
+
322
+ Returns:
323
+ TriSurf: The loaded mesh with textures if available
324
+ """
325
+ trimesh = optional_requirements.require_trimesh()
326
+ import tempfile
327
+
328
+ path_in = "file.obj"
329
+
330
+ # Create a temporary directory to store associated files
331
+ with tempfile.TemporaryDirectory() as temp_dir:
332
+ # Write the OBJ content to a temp file
333
+ obj_path = os.path.join(temp_dir, os.path.basename(path_in))
334
+ with open(obj_path, 'w') as f: # Use text mode 'w' for text files
335
+ obj_stream.seek(0)
336
+ f.write(obj_stream.read())
337
+ obj_stream.seek(0)
338
+
339
+ if mtl_stream is not None:
340
+ cls.write_material_files(
341
+ mtl_streams=mtl_stream,
342
+ obj_stream=obj_stream,
343
+ temp_dir=temp_dir,
344
+ texture_streams=texture_stream
345
+ )
346
+
347
+ # Now load the OBJ with all associated files available
348
+ scene_or_mesh = load_with_trimesh(
349
+ path_to_file_or_buffer=obj_path,
350
+ file_type="obj",
351
+ coordinate_system=coord_system
352
+ )
353
+
354
+ # Convert to a TriSurf object
355
+ tri_surf = TrimeshToSubsurface.trimesh_to_unstruct(scene_or_mesh)
356
+
357
+ return tri_surf
358
+
359
+ @classmethod
360
+ def write_material_files(cls, mtl_streams: list[TextIO], obj_stream: TextIO, temp_dir, texture_streams: list[io.BytesIO]):
361
+ # Extract mtl references from the OBJ file
362
+ mtl_files = cls._extract_mtl_references(obj_stream)
363
+ # Download and save MTL files
364
+ for e, mtl_file in enumerate(mtl_files):
365
+ mtl_path = f"{temp_dir}/{mtl_file}" if temp_dir else mtl_file
366
+ mtl_stream = mtl_streams[e] if mtl_streams else None
367
+ try:
368
+ # Save the MTL file to temp directory
369
+ mtl_temp_path = os.path.join(temp_dir, mtl_file)
370
+ with open(mtl_temp_path, 'w') as f: # Use text mode 'w' for text files
371
+ mtl_stream.seek(0)
372
+ f.write(mtl_stream.read())
373
+
374
+ # Extract texture references from MTL
375
+ mtl_stream.seek(0)
376
+ texture_files = cls._extract_texture_references(mtl_stream)
377
+
378
+ if texture_streams is None:
379
+ continue
380
+
381
+ # Download texture files
382
+ for ee, texture_file in enumerate(texture_files):
383
+ texture_path = f"{temp_dir}/{texture_file}" if temp_dir else texture_file
384
+ texture_stream = texture_streams[ee] if texture_streams else None
385
+ try:
386
+ # Save the texture file to temp directory
387
+ with open(os.path.join(temp_dir, texture_file), 'wb') as f: # Binary mode for textures
388
+ texture_stream.seek(0)
389
+ f.write(texture_stream.read())
390
+ except Exception as e:
391
+ print(f"Failed to load texture {texture_file}: {e}")
392
+ except Exception as e:
393
+ print(f"Failed to load MTL file {mtl_file}: {e}")
394
+
395
+ @classmethod
396
+ def _extract_mtl_references(cls, obj_stream):
397
+ """Extract MTL file references from an OBJ file."""
398
+ obj_stream.seek(0)
399
+ mtl_files = []
400
+
401
+ # TextIO stream already contains decoded text, so no need to decode
402
+ obj_text = obj_stream.read()
403
+ obj_stream.seek(0)
404
+
405
+ for line in obj_text.splitlines():
406
+ if line.startswith('mtllib '):
407
+ mtl_name = line.split(None, 1)[1].strip()
408
+ mtl_files.append(mtl_name)
409
+
410
+ return mtl_files
411
+
412
+ @classmethod
413
+ def _extract_texture_references(cls, mtl_stream):
414
+ """
415
+ Extract texture file references from an MTL file.
416
+ Works with both TextIO and BytesIO streams.
417
+
418
+ Parameters:
419
+ mtl_stream: TextIO or BytesIO containing the MTL file data
420
+
421
+ Returns:
422
+ list[str]: List of texture file names referenced in the MTL
423
+ """
424
+ mtl_stream.seek(0)
425
+ texture_files = []
426
+
427
+ # Handle both TextIO and BytesIO
428
+ if isinstance(mtl_stream, io.TextIOWrapper):
429
+ # TextIO stream already contains decoded text
430
+ mtl_text = mtl_stream.read()
431
+ else:
432
+ # BytesIO stream needs to be decoded
433
+ mtl_text = mtl_stream.read().decode('utf-8', errors='replace')
434
+
435
+ mtl_stream.seek(0)
436
+
437
+ for line in mtl_text.splitlines():
438
+ # Check for texture map definitions
439
+ for prefix in ['map_Kd ', 'map_Ka ', 'map_Ks ', 'map_Bump ', 'map_d ']:
440
+ if line.startswith(prefix):
441
+ parts = line.split(None, 1)
442
+ if len(parts) > 1:
443
+ texture_name = parts[1].strip()
444
+ texture_files.append(texture_name)
445
+ break
446
+
447
+ return texture_files