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