subsurface-terra 2025.1.0rc15__py3-none-any.whl → 2025.1.0rc17__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.
- subsurface/__init__.py +31 -31
- subsurface/_version.py +34 -21
- subsurface/api/__init__.py +13 -13
- subsurface/api/interfaces/__init__.py +3 -3
- subsurface/api/interfaces/stream.py +136 -136
- subsurface/api/reader/read_wells.py +78 -78
- subsurface/core/geological_formats/boreholes/_combine_trajectories.py +117 -117
- subsurface/core/geological_formats/boreholes/_map_attrs_to_survey.py +236 -234
- subsurface/core/geological_formats/boreholes/_survey_to_unstruct.py +163 -163
- subsurface/core/geological_formats/boreholes/boreholes.py +140 -140
- subsurface/core/geological_formats/boreholes/collars.py +26 -26
- subsurface/core/geological_formats/boreholes/survey.py +86 -86
- subsurface/core/geological_formats/fault.py +47 -47
- subsurface/core/reader_helpers/reader_unstruct.py +11 -11
- subsurface/core/reader_helpers/readers_data.py +130 -130
- subsurface/core/reader_helpers/readers_wells.py +13 -13
- subsurface/core/structs/__init__.py +3 -3
- subsurface/core/structs/base_structures/__init__.py +2 -2
- subsurface/core/structs/base_structures/_aux.py +69 -0
- subsurface/core/structs/base_structures/_liquid_earth_mesh.py +121 -121
- subsurface/core/structs/base_structures/_unstructured_data_constructor.py +70 -70
- subsurface/core/structs/base_structures/base_structures_enum.py +6 -6
- subsurface/core/structs/base_structures/structured_data.py +282 -282
- subsurface/core/structs/base_structures/unstructured_data.py +338 -319
- subsurface/core/structs/structured_elements/octree_mesh.py +10 -10
- subsurface/core/structs/structured_elements/structured_grid.py +59 -59
- subsurface/core/structs/structured_elements/structured_mesh.py +9 -9
- subsurface/core/structs/unstructured_elements/__init__.py +3 -3
- subsurface/core/structs/unstructured_elements/line_set.py +72 -72
- subsurface/core/structs/unstructured_elements/point_set.py +43 -43
- subsurface/core/structs/unstructured_elements/tetrahedron_mesh.py +35 -35
- subsurface/core/structs/unstructured_elements/triangular_surface.py +62 -62
- subsurface/core/utils/utils_core.py +38 -38
- subsurface/modules/reader/__init__.py +13 -13
- subsurface/modules/reader/faults/faults.py +80 -80
- subsurface/modules/reader/from_binary.py +46 -46
- subsurface/modules/reader/mesh/_GOCAD_mesh.py +82 -82
- subsurface/modules/reader/mesh/_trimesh_reader.py +447 -447
- subsurface/modules/reader/mesh/csv_mesh_reader.py +53 -53
- subsurface/modules/reader/mesh/dxf_reader.py +177 -177
- subsurface/modules/reader/mesh/glb_reader.py +30 -30
- subsurface/modules/reader/mesh/mx_reader.py +232 -232
- subsurface/modules/reader/mesh/obj_reader.py +53 -53
- subsurface/modules/reader/mesh/omf_mesh_reader.py +43 -43
- subsurface/modules/reader/mesh/surface_reader.py +56 -56
- subsurface/modules/reader/mesh/surfaces_api.py +41 -41
- subsurface/modules/reader/profiles/__init__.py +3 -3
- subsurface/modules/reader/profiles/profiles_core.py +197 -197
- subsurface/modules/reader/read_netcdf.py +38 -38
- subsurface/modules/reader/topography/__init__.py +7 -7
- subsurface/modules/reader/topography/topo_core.py +100 -100
- subsurface/modules/reader/volume/read_grav3d.py +447 -428
- subsurface/modules/reader/volume/read_volume.py +327 -230
- subsurface/modules/reader/volume/segy_reader.py +105 -105
- subsurface/modules/reader/volume/seismic.py +173 -173
- subsurface/modules/reader/volume/volume_utils.py +43 -43
- subsurface/modules/reader/wells/DEP/__init__.py +43 -43
- subsurface/modules/reader/wells/DEP/_well_files_reader.py +167 -167
- subsurface/modules/reader/wells/DEP/_wells_api.py +61 -61
- subsurface/modules/reader/wells/DEP/_welly_reader.py +180 -180
- subsurface/modules/reader/wells/DEP/pandas_to_welly.py +212 -212
- subsurface/modules/reader/wells/_read_to_df.py +57 -57
- subsurface/modules/reader/wells/read_borehole_interface.py +148 -148
- subsurface/modules/reader/wells/wells_utils.py +68 -68
- subsurface/modules/tools/mocking_aux.py +104 -104
- subsurface/modules/visualization/__init__.py +2 -2
- subsurface/modules/visualization/to_pyvista.py +320 -320
- subsurface/modules/writer/to_binary.py +12 -12
- subsurface/modules/writer/to_rex/common.py +78 -78
- subsurface/modules/writer/to_rex/data_struct.py +74 -74
- subsurface/modules/writer/to_rex/gempy_to_rexfile.py +791 -791
- subsurface/modules/writer/to_rex/material_encoder.py +44 -44
- subsurface/modules/writer/to_rex/mesh_encoder.py +152 -152
- subsurface/modules/writer/to_rex/to_rex.py +115 -115
- subsurface/modules/writer/to_rex/utils.py +15 -15
- subsurface/optional_requirements.py +116 -116
- {subsurface_terra-2025.1.0rc15.dist-info → subsurface_terra-2025.1.0rc17.dist-info}/METADATA +194 -194
- subsurface_terra-2025.1.0rc17.dist-info/RECORD +99 -0
- {subsurface_terra-2025.1.0rc15.dist-info → subsurface_terra-2025.1.0rc17.dist-info}/WHEEL +1 -1
- {subsurface_terra-2025.1.0rc15.dist-info → subsurface_terra-2025.1.0rc17.dist-info}/licenses/LICENSE +203 -203
- subsurface_terra-2025.1.0rc15.dist-info/RECORD +0 -98
- {subsurface_terra-2025.1.0rc15.dist-info → subsurface_terra-2025.1.0rc17.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
|