Coverage for trimesh/exchange/obj.py: 92%
383 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
1import itertools
2import re
3from collections import defaultdict, deque
5import numpy as np
7try:
8 # `pip install pillow`
9 # optional: used for textured meshes
10 from PIL import Image
11except BaseException as E:
12 # if someone tries to use Image re-raise
13 # the import error so they can debug easily
14 from ..exceptions import ExceptionWrapper
16 Image = ExceptionWrapper(E)
18from .. import util
19from ..constants import log, tol
20from ..resolvers import ResolverLike
21from ..typed import Loadable
22from ..visual.color import to_float
23from ..visual.material import SimpleMaterial
24from ..visual.texture import TextureVisuals, unmerge_faces
27def load_obj(
28 file_obj: Loadable,
29 resolver: ResolverLike | None = None,
30 group_material: bool = True,
31 skip_materials: bool = False,
32 maintain_order: bool = False,
33 metadata: dict | None = None,
34 split_objects: bool = False,
35 split_groups: bool = False,
36 **kwargs,
37) -> dict:
38 """
39 Load a Wavefront OBJ file into kwargs for a trimesh.Scene
40 object.
42 Parameters
43 --------------
44 file_obj : file like object
45 Contains OBJ data
46 resolver : trimesh.visual.resolvers.Resolver
47 Allow assets such as referenced textures and
48 material files to be loaded
49 group_material : bool
50 Group faces that share the same material
51 into the same mesh.
52 skip_materials
53 Don't load any materials.
54 maintain_order
55 Make the strongest attempt possible to not reorder faces
56 or vertices which may result in visual artifacts and other
57 odd behavior. The OBJ data structure is quite different than
58 the "flat matching array" used by Trimesh and GLTF so this may
59 not be completely possible.
60 split_objects
61 Whenever the loader encounters an `o` directive in the OBJ
62 file, split the loaded result into a new Trimesh object.
63 split_groups
64 Whenever the loader encounters a `g` directive in the OBJ
65 file, split the loaded result into a new Trimesh object.
67 Returns
68 -------------
69 kwargs : dict
70 Keyword arguments which can be loaded by
71 trimesh.exchange.load.load_kwargs into a trimesh.Scene
72 """
73 # get text as bytes or string blob
74 text = file_obj.read()
75 # if text was bytes decode into string
76 text = util.decode_text(text)
78 # add leading and trailing newlines so we can use the
79 # same logic even if they jump directly in to data lines
80 text = "\n{}\n".format(text.strip().replace("\r\n", "\n"))
82 # remove backslash continuation characters and merge them into the same
83 # line
84 text = text.replace("\\\n", "")
86 # Load Materials
87 materials = {}
88 mtl_position = text.find("mtllib")
89 if not skip_materials and mtl_position >= 0:
90 # take the line of the material file after `mtllib`
91 # which should be the file location of the .mtl file
92 mtl_path = text[mtl_position + 6 : text.find("\n", mtl_position)].strip()
93 try:
94 # use the resolver to get the data
95 material_kwargs = parse_mtl(resolver[mtl_path], resolver=resolver)
96 # turn parsed kwargs into material objects
97 materials = {k: SimpleMaterial(**v) for k, v in material_kwargs.items()}
98 except (OSError, TypeError):
99 # usually the resolver couldn't find the asset
100 log.debug(f"unable to load materials from: {mtl_path}")
101 except BaseException:
102 # something else happened so log a warning
103 log.debug(f"unable to load materials from: {mtl_path}", exc_info=True)
105 # extract vertices from raw text
106 v, vn, vt, vc = _parse_vertices(text=text)
108 # get relevant chunks that have face data
109 # in the form of (material, object, chunk)
110 face_tuples = _preprocess_faces(text, split_objects, split_groups)
112 # combine chunks that have the same material
113 # some meshes end up with a LOT of components
114 # and will be much slower if you don't do this
115 if group_material or split_objects or split_groups:
116 face_tuples = _group_by(face_tuples, group_material, split_objects, split_groups)
118 # no faces but points given
119 # return point cloud
120 if not len(face_tuples) and v is not None:
121 pc = {"vertices": v}
122 if vn is not None:
123 pc["vertex_normals"] = vn
124 if vc is not None:
125 pc["vertex_colors"] = vc
126 return pc
128 # Load Faces
129 # now we have clean- ish faces grouped by material and object
130 # so now we have to turn them into numpy arrays and kwargs
131 # for trimesh mesh and scene objects
132 geometry = {}
133 while len(face_tuples) > 0:
134 # consume the next chunk of text
135 material, current_object, current_group, chunk = face_tuples.pop()
136 # do wangling in string form
137 # we need to only take the face line before a newline
138 # using builtin functions in a list comprehension
139 # is pretty fast relative to other options
140 # this operation is the only one that is O(len(faces))
141 # slower due to the tight-loop conditional:
142 # face_lines = [i[:i.find('\n')]
143 # for i in chunk.split('\nf ')[1:]
144 # if i.rfind('\n') >0]
145 # maxsplit=1 means that it can stop working
146 # after it finds the first newline
147 # passed as arg as it's not a kwarg in python2
148 face_lines = [
149 i.split("\n", 1)[0].strip()
150 for i in re.split("^f", chunk, flags=re.MULTILINE)[1:]
151 ]
153 # check every face for mixed tri-quad-ngon
154 columns = len(face_lines[0].replace("/", " ").split())
155 flat_array = all(columns == len(f.replace("/", " ").split()) for f in face_lines)
157 # make sure we have the right number of values for vectorized
158 if flat_array:
159 # the fastest way to get to a numpy array
160 # processes the whole string at once into a 1D array
161 array = np.fromstring(
162 " ".join(face_lines).replace("/", " "), sep=" ", dtype=np.int64
163 )
164 # also wavefront is 1-indexed (vs 0-indexed) so offset
165 # only applies to positive indices
166 array[array > 0] -= 1
168 # everything is a nice 2D array
169 faces, faces_tex, faces_norm = _parse_faces_vectorized(
170 array=array, columns=columns, sample_line=face_lines[0]
171 )
172 else:
173 # if we had something annoying like mixed in quads
174 # or faces that differ per-line we have to loop
175 # i.e. something like:
176 # '31407 31406 31408',
177 # '32303/2469 32304/2469 32305/2469',
178 log.debug("faces have mixed data: using slow fallback!")
179 faces, faces_tex, faces_norm = _parse_faces_fallback(face_lines)
181 # build name from components
182 name_parts = []
183 if split_objects and current_object is not None:
184 name_parts.append(current_object)
185 if split_groups and current_group is not None:
186 name_parts.append(current_group)
187 if group_material and len(materials) > 1 and material is not None:
188 name_parts.append(str(material))
190 # join parts or fall back to defaults
191 if name_parts:
192 name = "_".join(name_parts)
193 elif current_object is not None:
194 name = current_object
195 else:
196 # try to use the file name from the resolver
197 # or file object if possible before defaulting
198 name = next(
199 i
200 for i in (
201 getattr(resolver, "file_name", None),
202 getattr(file_obj, "name", None),
203 "geometry",
204 )
205 if i is not None
206 )
208 # ensure the name is always unique in the geometry dict
209 name = util.unique_name(name, geometry)
211 # try to get usable texture
212 mesh = kwargs.copy()
213 if faces_tex is not None:
214 # convert faces referencing vertices and
215 # faces referencing vertex texture to new faces
216 # where each face
217 if faces_norm is not None and len(faces_norm) == len(faces):
218 new_faces, mask_v, mask_vt, mask_vn = unmerge_faces(
219 faces, faces_tex, faces_norm, maintain_faces=maintain_order
220 )
221 else:
222 mask_vn = None
223 # no face normals but face texturre
224 new_faces, mask_v, mask_vt = unmerge_faces(
225 faces, faces_tex, maintain_faces=maintain_order
226 )
228 if tol.strict:
229 # we should NOT have messed up the faces
230 # note: this is EXTREMELY slow due to all the
231 # float comparisons so only run this in unit tests
232 assert np.allclose(v[faces], v[mask_v][new_faces])
233 # faces should all be in bounds of vertives
234 assert new_faces.max() < len(v[mask_v])
235 try:
236 # survive index errors as sometimes we
237 # want materials without UV coordinates
238 uv = vt[mask_vt]
239 except BaseException:
240 log.debug("index failed on UV coordinates, skipping!")
241 uv = None
243 # mask vertices and use new faces
244 mesh.update({"vertices": v[mask_v].copy(), "faces": new_faces})
246 else:
247 # otherwise just use unmasked vertices
248 uv = None
249 # check to make sure indexes are in bounds
250 if tol.strict:
251 assert faces.max() < len(v)
252 if vn is not None and np.shape(faces_norm) == faces.shape:
253 # do the crazy unmerging logic for split indices
254 new_faces, mask_v, mask_vn = unmerge_faces(
255 faces, faces_norm, maintain_faces=maintain_order
256 )
257 else:
258 # face_tex is None and
259 # generate the mask so we only include
260 # referenced vertices in every new mesh
261 if maintain_order:
262 mask_v = np.ones(len(v), dtype=bool)
263 else:
264 mask_v = np.zeros(len(v), dtype=bool)
265 mask_v[faces] = True
267 # reconstruct the faces with the new vertex indices
268 inverse = np.zeros(len(v), dtype=np.int64)
269 inverse[mask_v] = np.arange(mask_v.sum())
270 new_faces = inverse[faces]
271 # no normals
272 mask_vn = None
274 # start with vertices and faces
275 mesh.update({"faces": new_faces, "vertices": v[mask_v].copy()})
277 # if colors and normals are OK save them
278 if vc is not None:
279 try:
280 # may fail on a malformed color mask
281 mesh["vertex_colors"] = vc[mask_v]
282 except BaseException:
283 log.debug("failed to load vertex_colors", exc_info=True)
284 if mask_vn is not None:
285 try:
286 # may fail on a malformed mask
287 normals = vn[mask_vn]
288 if normals.shape != mesh["vertices"].shape:
289 raise ValueError(
290 "incorrect normals {} != {}".format(
291 str(normals.shape), str(mesh["vertices"].shape)
292 )
293 )
294 mesh["vertex_normals"] = normals
295 except BaseException:
296 log.debug("failed to load vertex_normals", exc_info=True)
298 visual = None
299 if material in materials:
300 # use the material with the UV coordinates
301 visual = TextureVisuals(uv=uv, material=materials[material])
302 elif uv is not None and len(uv) == len(mesh["vertices"]):
303 # create a texture with an empty materials
304 visual = TextureVisuals(uv=uv)
305 elif material is not None:
306 # case where material is specified but not available
307 log.debug(f"specified material ({material}) not loaded!")
308 # assign the visual
309 mesh["visual"] = visual
310 # store geometry by name
311 geometry[name] = mesh
313 # add an identity transform for every geometry
314 graph = [{"geometry": k, "frame_to": k} for k in geometry.keys()]
316 # convert to scene kwargs
317 return {"geometry": geometry, "graph": graph}
320def parse_mtl(mtl, resolver=None):
321 """
322 Parse a loaded MTL file.
324 Parameters
325 -------------
326 mtl : str or bytes
327 Data from an MTL file
328 resolver : trimesh.Resolver
329 Fetch assets by name from file system, web, or other
331 Returns
332 ------------
333 mtllibs : list of dict
334 Each dict has keys: newmtl, map_Kd, Kd
335 """
336 # decode bytes into string if necessary
337 mtl = util.decode_text(mtl)
339 # current material
340 material = None
341 # materials referenced by name
342 materials = {}
343 # use universal newline splitting
344 lines = str.splitlines(str(mtl).strip())
346 # remap OBJ property names to kwargs for SimpleMaterial
347 mapped = {"kd": "diffuse", "ka": "ambient", "ks": "specular", "ns": "glossiness"}
349 for line in lines:
350 # split by white space
351 split = line.strip().split()
352 # needs to be at least two values
353 if len(split) <= 1:
354 continue
355 # the first value is the parameter name
356 key = split[0].lower()
357 # start a new material
358 if key == "newmtl":
359 # material name extracted from line like:
360 # newmtl material_0
361 if material is not None:
362 # save the old material by old name and remove key
363 materials[material["name"]] = material
364 # start a fresh new material
365 # do we really want to support material names with whitespace?
366 material = {"name": " ".join(split[1:])}
368 elif key == "map_kd":
369 # represents the file name of the texture image
370 index = line.lower().index("map_kd") + 6
371 file_name = line[index:].strip()
372 try:
373 file_data = resolver.get(file_name)
374 # load the bytes into a PIL image
375 # an image file name
376 material["image"] = Image.open(util.wrap_as_stream(file_data))
377 # record the texture reference as written in the MTL
378 material["image"].info["file_path"] = file_name
380 except BaseException:
381 log.debug("failed to load image", exc_info=True)
383 elif key in mapped.keys():
384 try:
385 # diffuse, ambient, and specular float RGB
386 value = [float(x) for x in split[1:]]
387 # if there is only one value return that
388 if len(value) == 1:
389 value = value[0]
390 if material is not None:
391 # store the key by mapped value
392 material[mapped[key]] = value
393 # also store key by OBJ name
394 material[key] = value
395 except BaseException:
396 log.debug("failed to convert color!", exc_info=True)
397 # pass everything as kwargs to material constructor
398 elif material is not None:
399 # save any other unspecified keys
400 material[key] = split[1:]
401 # reached EOF so save any existing materials
402 if material:
403 materials[material["name"]] = material
405 return materials
408def _parse_faces_vectorized(array, columns, sample_line):
409 """
410 Parse loaded homogeneous (tri/quad) face data in a
411 vectorized manner.
413 Parameters
414 ------------
415 array : (n,) int
416 Indices in order
417 columns : int
418 Number of columns in the file
419 sample_line : str
420 A single line so we can assess the ordering
422 Returns
423 --------------
424 faces : (n, d) int
425 Faces in space
426 faces_tex : (n, d) int or None
427 Texture for each vertex in face
428 faces_norm : (n, d) int or None
429 Normal index for each vertex in face
430 """
431 # reshape to columns
432 array = array.reshape((-1, columns))
433 # how many elements are in the first line of faces
434 # i.e '13/1/13 14/1/14 2/1/2 1/2/1' is 4
435 group_count = len(sample_line.strip().split())
436 # how many elements are there for each vertex reference
437 # i.e. '12/1/13' is 3
438 per_ref = int(columns / group_count)
439 # create an index mask we can use to slice vertex references
440 index = np.arange(group_count) * per_ref
441 # slice the faces out of the blob array
442 faces = array[:, index]
444 # TODO: probably need to support 8 and 12 columns for quads
445 # or do something more general
446 faces_tex, faces_norm = None, None
447 if columns == group_count * 2:
448 # if we have two values per vertex the second
449 # one is index of texture coordinate (`vt`)
450 # count how many delimiters are in the first face line
451 # to see if our second value is texture or normals
452 # do splitting to clip off leading/trailing slashes
453 count = "".join(i.strip("/") for i in sample_line.split()).count("/")
454 if count == columns:
455 # case where each face line looks like:
456 # ' 75//139 76//141 77//141'
457 # which is vertex/nothing/normal
458 faces_norm = array[:, index + 1]
459 elif count == int(columns / 2):
460 # case where each face line looks like:
461 # '75/139 76/141 77/141'
462 # which is vertex/texture
463 faces_tex = array[:, index + 1]
464 else:
465 log.debug(f"face lines are weird: {sample_line}")
466 elif columns == group_count * 3:
467 # if we have three values per vertex
468 # second value is always texture
469 faces_tex = array[:, index + 1]
470 # third value is reference to vertex normal (`vn`)
471 faces_norm = array[:, index + 2]
472 return faces, faces_tex, faces_norm
475def _parse_faces_fallback(lines):
476 """
477 Use a slow but more flexible looping method to process
478 face lines as a fallback option to faster vectorized methods.
480 Parameters
481 -------------
482 lines : (n,) str
483 List of lines with face information
485 Returns
486 -------------
487 faces : (m, 3) int
488 Clean numpy array of face triangles
489 """
491 # collect vertex, texture, and vertex normal indexes
492 v, vt, vn = [], [], []
494 # loop through every line starting with a face
495 for line in lines:
496 # remove leading newlines then
497 # take first bit before newline then split by whitespace
498 split = line.strip().split("\n")[0].split()
499 # split into: ['76/558/76', '498/265/498', '456/267/456']
500 len_split = len(split)
501 if len_split == 3:
502 pass
503 elif len_split == 4:
504 # triangulate quad face
505 split = [split[0], split[1], split[2], split[2], split[3], split[0]]
506 elif len_split > 4:
507 # triangulate polygon as a triangles fan
508 collect = []
509 # we need a flat list so append inside
510 # a list comprehension
511 collect_append = collect.append
512 [
513 [
514 collect_append(split[0]),
515 collect_append(split[i + 1]),
516 collect_append(split[i + 2]),
517 ]
518 for i in range(len(split) - 2)
519 ]
520 split = collect
521 else:
522 log.debug(f"face needs more values 3>{len(split)} skipping!")
523 continue
525 # f is like: '76/558/76'
526 for f in split:
527 # vertex, vertex texture, vertex normal
528 split = f.split("/")
529 # we always have a vertex reference
530 v.append(int(split[0]))
532 # faster to try/except than check in loop
533 try:
534 vt.append(int(split[1]))
535 except BaseException:
536 pass
537 try:
538 # vertex normal is the third index
539 vn.append(int(split[2]))
540 except BaseException:
541 pass
543 # shape into triangles and switch to 0-indexed
544 # 0-indexing only applies to positive indices
545 faces = np.array(v, dtype=np.int64).reshape((-1, 3))
546 faces[faces > 0] -= 1
547 faces_tex, normals = None, None
548 if len(vt) == len(v):
549 faces_tex = np.array(vt, dtype=np.int64).reshape((-1, 3))
550 faces_tex[faces_tex > 0] -= 1
551 if len(vn) == len(v):
552 normals = np.array(vn, dtype=np.int64).reshape((-1, 3))
553 normals[normals > 0] -= 1
555 return faces, faces_tex, normals
558def _parse_vertices(text):
559 """
560 Parse raw OBJ text into vertices, vertex normals,
561 vertex colors, and vertex textures.
563 Parameters
564 -------------
565 text : str
566 Full text of an OBJ file
568 Returns
569 -------------
570 v : (n, 3) float
571 Vertices in space
572 vn : (m, 3) float or None
573 Vertex normals
574 vt : (p, 2) float or None
575 Vertex texture coordinates
576 vc : (n, 3) float or None
577 Per-vertex color
578 """
580 # the first position of a vertex in the text blob
581 # we only really need to search from the start of the file
582 # up to the location of out our first vertex but we
583 # are going to use this check for "do we have texture"
584 # determination later so search the whole stupid file
585 starts = {k: text.find(f"\n{k} ") for k in ["v", "vt", "vn"]}
587 # no valid values so exit early
588 if not any(v >= 0 for v in starts.values()):
589 return None, None, None, None
591 # find the last position of each valid value
592 ends = {
593 k: text.find("\n", text.rfind(f"\n{k} ") + 2 + len(k))
594 for k, v in starts.items()
595 if v >= 0
596 }
598 # take the first and last position of any vertex property
599 start = min(s for s in starts.values() if s >= 0)
600 end = max(e for e in ends.values() if e >= 0)
601 # get the chunk of test that contains vertex data
602 chunk = text[start:end].replace("+e", "e").replace("-e", "e")
604 # get the clean-ish data from the file as python lists
605 data = {
606 k: [i.split("\n", 1)[0] for i in chunk.split(f"\n{k} ")[1:]]
607 for k, v in starts.items()
608 if v >= 0
609 }
611 # count the number of data values per row on a sample row
612 per_row = {k: len(v[0].split()) for k, v in data.items()}
614 # convert data values into numpy arrays
615 result = defaultdict(lambda: None)
616 for k, value in data.items():
617 # use joining and fromstring to get as numpy array
618 array = np.fromstring(" ".join(value), sep=" ", dtype=np.float64)
619 # what should our shape be
620 shape = (len(value), per_row[k])
621 # check shape of flat data
622 if len(array) == np.prod(shape):
623 # we have a nice 2D array
624 result[k] = array.reshape(shape)
625 else:
626 # we don't have a nice (n, d) array so fall back to a slow loop
627 # this is where mixed "some of the values but not all have vertex colors"
628 # problem is handled.
629 lines = []
630 [[lines.append(v.strip().split()) for v in str.splitlines(i)] for i in value]
631 # we need to make a 2D array so clip it to the shortest array
632 count = min(len(L) for L in lines)
633 # make a numpy array out of the cleaned up line data
634 result[k] = np.array([L[:count] for L in lines], dtype=np.float64)
636 # vertices
637 v = result["v"]
638 # vertex colors are stored next to vertices
639 vc = None
640 if v is not None and v.shape[1] >= 6:
641 # vertex colors are stored after vertices
642 v, vc = v[:, :3], v[:, 3:6]
643 elif v is not None and v.shape[1] > 3:
644 # we got a lot of something unknowable
645 v = v[:, :3]
647 # vertex texture or None
648 vt = result["vt"]
649 if vt is not None:
650 # sometimes UV coordinates come in as UVW
651 vt = vt[:, :2]
652 # vertex normals or None
653 vn = result["vn"]
655 # check will generally only be run in unit tests
656 # so we are allowed to do things that are slow
657 if tol.strict:
658 # check to make sure our subsetting
659 # didn't miss any vertices or data
660 assert len(v) == text.count("\nv ")
661 # make sure optional data matches file too
662 if vn is not None:
663 assert len(vn) == text.count("\nvn ")
664 if vt is not None:
665 assert len(vt) == text.count("\nvt ")
667 return v, vn, vt, vc
670def _group_by(face_tuples, use_mtl: bool, use_obj: bool, use_group: bool):
671 """
672 For chunks of faces split by material group
673 the chunks that share the same material.
675 Parameters
676 ------------
677 face_tuples : (n,) list of (material, obj, chunk)
678 The data containing faces
679 use_mtl
680 Group tuples by `usemtl` commands
681 use_obj
682 Group tuples by `o` commands
683 use_group
684 Group tuples by `g` commands
686 Returns
687 ------------
688 grouped : (m,) list of tuples containing:
689 `(material name, objectname, group name, raw chunk)`
690 """
692 # store the chunks grouped by material
693 grouped = defaultdict(lambda: ["", "", "", []])
694 # loop through existring
695 for material, obj, group, chunk in face_tuples:
696 # tuple key for the dict
697 key = (
698 material if use_mtl else None,
699 obj if use_obj else None,
700 group if use_group else None,
701 )
702 grouped[key][0] = material
703 grouped[key][1] = obj
704 grouped[key][2] = group
705 # don't do a million string concatenations in loop
706 grouped[key][3].append(chunk)
707 # go back and do a join to make a single string
708 for key in grouped.keys():
709 grouped[key][3] = "\n".join(grouped[key][3])
710 # return as list
711 return list(grouped.values())
714def _preprocess_faces(text, use_obj=False, use_groups=False):
715 """
716 Pre-Process Face Text
718 Rather than looking at each line in a loop we're
719 going to split lines by directives which indicate
720 a new mesh, specifically 'usemtl', 'o', and 'g' keys
721 search for materials, objects, faces, or groups
723 Parameters
724 ------------
725 text : str
726 Raw file
728 Returns
729 ------------
730 triple : (n, 3) tuple
731 Tuples of (material, object, data-chunk)
732 """
733 # see which chunk is relevant
734 starters = ["\nusemtl ", "\no ", "\nf ", "\ng ", "\ns "]
735 f_start = len(text)
736 # first index of material, object, face, group, or smoother
737 for st in starters:
738 search = text.find(st, 0, f_start)
739 # if not contained find will return -1
740 if search < 0:
741 continue
742 # subtract the length of the key from the position
743 # to make sure it's included in the slice of text
744 if search < f_start:
745 f_start = search
746 # index in blob of the newline after the last face
747 f_end = text.find("\n", text.rfind("\nf ") + 3)
748 # get the chunk of the file that has face information
749 if f_end >= 0:
750 # clip to the newline after the last face
751 f_chunk = text[f_start:f_end]
752 else:
753 # no newline after last face
754 f_chunk = text[f_start:]
756 if tol.strict:
757 # check to make sure our subsetting didn't miss any faces
758 assert f_chunk.count("\nf ") == text.count("\nf ")
760 # two things cause new meshes to be created:
761 # objects and materials
762 # re.finditer was faster than find in a loop
763 # find the index of every material change
764 idx_mtl = np.array([m.start(0) for m in re.finditer("usemtl ", f_chunk)], dtype=int)
765 # find the index of every new object
766 split_idxs = [[0, len(f_chunk)], idx_mtl]
768 # NOTE: This used to split objects on every run, but now it does not
769 if use_obj:
770 split_idxs.append(
771 np.array([m.start(0) for m in re.finditer("\no ", f_chunk)], dtype=int)
772 )
774 if use_groups:
775 split_idxs.append(
776 np.array([m.start(0) for m in re.finditer("\ng ", f_chunk)], dtype=int)
777 )
779 # find all the indexes where we want to split
780 splits = np.unique(np.concatenate(tuple(split_idxs)))
782 # track the current material and object ID
783 current_obj = None
784 current_mtl = None
785 current_group = None
786 # store (material, object, group, face lines)
787 face_tuples = []
789 for start, end in itertools.pairwise(splits):
790 # ensure there's always a trailing newline
791 chunk = f_chunk[start:end].strip() + "\n"
792 if chunk.startswith("o "):
793 current_obj, chunk = chunk.split("\n", 1)
794 current_obj = current_obj[2:].strip()
795 elif chunk.startswith("usemtl"):
796 current_mtl, chunk = chunk.split("\n", 1)
797 current_mtl = current_mtl[6:].strip()
798 # Discard the g tag line in the list of faces
799 elif chunk.startswith("g "):
800 current_group, chunk = chunk.split("\n", 1)
801 current_group = current_group[2:].strip()
802 # If we have an f at the beginning of a line
803 # then add it to the list of faces chunks
804 if chunk.startswith("f ") or "\nf" in chunk:
805 face_tuples.append((current_mtl, current_obj, current_group, chunk))
806 return face_tuples
809def export_obj(
810 mesh,
811 include_normals=None,
812 include_color=True,
813 include_texture=True,
814 return_texture=False,
815 write_texture=True,
816 resolver=None,
817 digits=8,
818 mtl_name=None,
819 header="https://github.com/mikedh/trimesh",
820):
821 """
822 Export a mesh as a Wavefront OBJ file.
823 TODO: scenes with textured meshes
825 Parameters
826 -----------
827 mesh : trimesh.Trimesh
828 Mesh to be exported
829 include_normals : bool or None
830 Include vertex normals in export. If None
831 will only be included if vertex normals are in cache.
832 include_color : bool
833 Include vertex color in export
834 include_texture : bool
835 Include `vt` texture in file text
836 return_texture : bool
837 If True, return a dict with texture files
838 write_texture : bool
839 If True and a writable resolver is passed
840 write the referenced texture files with resolver
841 resolver : None or trimesh.resolvers.Resolver
842 Resolver which can write referenced text objects
843 digits : int
844 Number of digits to include for floating point
845 mtl_name : None or str
846 If passed, the file name of the MTL file.
847 header : str or None
848 Header string for top of file or None for no header.
850 Returns
851 -----------
852 export : str
853 OBJ format output
854 texture : dict
855 Contains files that need to be saved in the same
856 directory as the exported mesh: {file name : bytes}
857 """
858 # store the multiple options for formatting
859 # vertex indexes for faces
860 face_formats = {
861 ("v",): "{}",
862 ("v", "vn"): "{}//{}",
863 ("v", "vt"): "{}/{}",
864 ("v", "vn", "vt"): "{}/{}/{}",
865 }
867 # check the input
868 if util.is_instance_named(mesh, "Trimesh"):
869 meshes = [mesh]
870 elif util.is_instance_named(mesh, "Scene"):
871 meshes = mesh.dump()
872 elif util.is_instance_named(mesh, "PointCloud"):
873 meshes = [mesh]
874 else:
875 raise ValueError("must be Trimesh or Scene!")
877 # collect lines to export
878 objects = deque()
879 # keep track of the number of each export element
880 counts = {"v": 0, "vn": 0, "vt": 0}
881 # collect materials as we go
882 materials = {}
883 materials_name = set()
885 for current in meshes:
886 # we are going to reference face_formats with this
887 face_type = ["v"]
888 # OBJ includes vertex color as RGB elements on the same line
889 if (
890 include_color
891 and current.visual.kind in ["vertex", "face"]
892 and len(current.visual.vertex_colors)
893 ):
894 # create a stacked blob with position and color
895 v_blob = np.column_stack(
896 (current.vertices, to_float(current.visual.vertex_colors[:, :3]))
897 )
898 else:
899 # otherwise just export vertices
900 v_blob = current.vertices
902 # add the first vertex key and convert the array
903 # add the vertices
904 export = deque(
905 [
906 "v "
907 + util.array_to_string(
908 v_blob, col_delim=" ", row_delim="\nv ", digits=digits
909 )
910 ]
911 )
913 # if include_normals is None then
914 # only include if they're already stored
915 if include_normals is None:
916 include_normals = "vertex_normals" in current._cache.cache
918 if include_normals:
919 try:
920 converted = util.array_to_string(
921 current.vertex_normals,
922 col_delim=" ",
923 row_delim="\nvn ",
924 digits=digits,
925 )
926 # if vertex normals are stored in cache export them
927 face_type.append("vn")
928 export.append("vn " + converted)
929 except BaseException:
930 log.debug("failed to convert vertex normals", exc_info=True)
932 # collect materials into a dict
933 if include_texture and hasattr(current.visual, "uv"):
934 try:
935 # get a SimpleMaterial
936 material = current.visual.material
937 if hasattr(material, "to_simple"):
938 material = material.to_simple()
940 # hash the material to avoid duplicates
941 hashed = hash(material)
942 if hashed not in materials:
943 # get a unique name for the material
944 name = util.unique_name(material.name, materials_name)
945 # add the name to our collection
946 materials_name.add(name)
947 # convert material to an OBJ MTL
948 materials[hashed] = material.to_obj(name=name)
950 # get the name of the current material as-stored
951 tex_name = materials[hashed][1]
953 # export the UV coordinates
954 if len(np.shape(getattr(current.visual, "uv", None))) == 2:
955 converted = util.array_to_string(
956 current.visual.uv, col_delim=" ", row_delim="\nvt ", digits=digits
957 )
958 # if vertex texture exists and is the right shape
959 face_type.append("vt")
960 # add the uv coordinates
961 export.append("vt " + converted)
962 # add the directive to use the exported material
963 export.appendleft(f"usemtl {tex_name}")
964 except BaseException:
965 log.debug("failed to convert UV coordinates", exc_info=True)
967 # the format for a single vertex reference of a face
968 face_format = face_formats[tuple(face_type)]
969 # add the exported faces to the export if available
970 if hasattr(current, "faces"):
971 export.append(
972 "f "
973 + util.array_to_string(
974 current.faces + 1 + counts["v"],
975 col_delim=" ",
976 row_delim="\nf ",
977 value_format=face_format,
978 )
979 )
980 # offset our vertex position
981 counts["v"] += len(current.vertices)
983 # add object name if found in metadata
984 if "name" in current.metadata:
985 export.appendleft("\no {}".format(current.metadata["name"]))
986 # add this object
987 objects.append("\n".join(export))
989 # collect files like images to write
990 mtl_data = {}
991 # combine materials
992 if len(materials) > 0:
993 # collect text for a single mtllib file
994 mtl_lib = []
995 # now loop through: keys are garbage hash
996 # values are (data, name)
997 for data, _ in materials.values():
998 for file_name, file_data in data.items():
999 if file_name.lower().endswith(".mtl"):
1000 # collect mtl lines into single file
1001 mtl_lib.append(file_data)
1002 elif file_name not in mtl_data:
1003 # things like images
1004 mtl_data[file_name] = file_data
1005 else:
1006 log.warning(f"not writing {file_name}")
1008 if mtl_name is None:
1009 # if no name passed set a default
1010 mtl_name = "material.mtl"
1012 # prepend a header to the MTL text if requested
1013 if header is not None:
1014 prepend = f"# {header}\n\n".encode()
1015 else:
1016 prepend = b""
1018 # save the material data
1019 mtl_data[mtl_name] = prepend + b"\n\n".join(mtl_lib)
1020 # add the reference to the MTL file
1021 objects.appendleft(f"mtllib {mtl_name}")
1023 if header is not None:
1024 # add a created-with header to the top of the file
1025 objects.appendleft(f"# {header}")
1027 # add a trailing newline
1028 objects.append("\n")
1030 # combine elements into a single string
1031 text = "\n".join(objects)
1033 # if we have a resolver and have asked to write texture
1034 if write_texture and resolver is not None and len(materials) > 0:
1035 # not all resolvers have a write method
1036 [resolver.write(k, v) for k, v in mtl_data.items()]
1038 # if we exported texture it changes returned values
1039 if return_texture:
1040 return text, mtl_data
1042 return text
1045_obj_loaders = {"obj": load_obj}