import numpy as np import re from collections import deque, defaultdict try: # `pip install pillow` # optional: used for textured meshes from PIL import Image except BaseException as E: # if someone tries to use Image re-raise # the import error so they can debug easily from ..exceptions import ExceptionModule Image = ExceptionModule(E) from .. import util from ..visual.color import to_float from ..visual.texture import unmerge_faces, TextureVisuals from ..visual.material import SimpleMaterial from ..constants import log, tol def load_obj(file_obj, resolver=None, group_material=True, skip_materials=False, maintain_order=False, **kwargs): """ Load a Wavefront OBJ file into kwargs for a trimesh.Scene object. Parameters -------------- file_obj : file like object Contains OBJ data resolver : trimesh.visual.resolvers.Resolver Allow assets such as referenced textures and material files to be loaded group_material : bool Group faces that share the same material into the same mesh. skip_materials : bool Don't load any materials. maintain_order : bool or None Do not reorder faces or vertices which may result in visual artifacts. Returns ------------- kwargs : dict Keyword arguments which can be loaded by trimesh.exchange.load.load_kwargs into a trimesh.Scene """ # get text as bytes or string blob text = file_obj.read() # if text was bytes decode into string text = util.decode_text(text) # add leading and trailing newlines so we can use the # same logic even if they jump directly in to data lines text = '\n{}\n'.format(text.strip().replace('\r\n', '\n')) # remove backslash continuation characters and merge them into the same line text = text.replace('\\\n', '') # Load Materials materials = {} mtl_position = text.find('mtllib') if not skip_materials and mtl_position >= 0: # take the line of the material file after `mtllib` # which should be the file location of the .mtl file mtl_path = text[mtl_position + 6:text.find('\n', mtl_position)].strip() try: # use the resolver to get the data material_kwargs = parse_mtl(resolver[mtl_path], resolver=resolver) # turn parsed kwargs into material objects materials = {k: SimpleMaterial(**v) for k, v in material_kwargs.items()} except (IOError, TypeError): # usually the resolver couldn't find the asset log.debug('unable to load materials from: {}'.format(mtl_path)) except BaseException: # something else happened so log a warning log.debug('unable to load materials from: {}'.format(mtl_path), exc_info=True) # extract vertices from raw text v, vn, vt, vc = _parse_vertices(text=text) # get relevant chunks that have face data # in the form of (material, object, chunk) face_tuples = _preprocess_faces(text=text) # combine chunks that have the same material # some meshes end up with a LOT of components # and will be much slower if you don't do this if group_material: face_tuples = _group_by_material(face_tuples) # no faces but points given # return point cloud if not len(face_tuples) and v is not None: pc = {'vertices': v} if vn is not None: pc['vertex_normals'] = vn if vc is not None: pc['vertex_colors'] = vc return pc # Load Faces # now we have clean- ish faces grouped by material and object # so now we have to turn them into numpy arrays and kwargs # for trimesh mesh and scene objects geometry = {} while len(face_tuples) > 0: # consume the next chunk of text material, current_object, chunk = face_tuples.pop() # do wangling in string form # we need to only take the face line before a newline # using builtin functions in a list comprehension # is pretty fast relative to other options # this operation is the only one that is O(len(faces)) # slower due to the tight-loop conditional: # face_lines = [i[:i.find('\n')] # for i in chunk.split('\nf ')[1:] # if i.rfind('\n') >0] # maxsplit=1 means that it can stop working # after it finds the first newline # passed as arg as it's not a kwarg in python2 face_lines = [i.split('\n', 1)[0] for i in chunk.split('f ')[1:]] # then we are going to replace all slashes with spaces joined = ' '.join(face_lines).replace('/', ' ') # the fastest way to get to a numpy array # processes the whole string at once into a 1D array array = np.fromstring(joined, sep=' ', dtype=np.int64) # also wavefront is 1-indexed (vs 0-indexed) so offset # only applies to positive indices array[array > 0] -= 1 # get the number of raw 2D columns in a sample line columns = len(face_lines[0].strip().replace('/', ' ').split()) # make sure we have the right number of values for vectorized if len(array) == (columns * len(face_lines)): # everything is a nice 2D array faces, faces_tex, faces_norm = _parse_faces_vectorized( array=array, columns=columns, sample_line=face_lines[0]) else: # if we had something annoying like mixed in quads # or faces that differ per-line we have to loop # i.e. something like: # '31407 31406 31408', # '32303/2469 32304/2469 32305/2469', log.debug('faces have mixed data: using slow fallback!') faces, faces_tex, faces_norm = _parse_faces_fallback( face_lines) if group_material: name = material else: name = current_object # ensure the name is always unique name = util.unique_name(name, geometry) # try to get usable texture mesh = kwargs.copy() if faces_tex is not None: # convert faces referencing vertices and # faces referencing vertex texture to new faces # where each face if faces_norm is not None and len(faces_norm) == len(faces): new_faces, mask_v, mask_vt, mask_vn = unmerge_faces( faces, faces_tex, faces_norm, maintain_faces=maintain_order) else: mask_vn = None # no face normals but face texturre new_faces, mask_v, mask_vt = unmerge_faces( faces, faces_tex, maintain_faces=maintain_order) if tol.strict: # we should NOT have messed up the faces # note: this is EXTREMELY slow due to all the # float comparisons so only run this in unit tests assert np.allclose(v[faces], v[mask_v][new_faces]) # faces should all be in bounds of vertives assert new_faces.max() < len(v[mask_v]) try: # survive index errors as sometimes we # want materials without UV coordinates uv = vt[mask_vt] except BaseException: log.debug('index failed on UV coordinates, skipping!') uv = None # mask vertices and use new faces mesh.update({'vertices': v[mask_v].copy(), 'faces': new_faces}) else: # otherwise just use unmasked vertices uv = None # check to make sure indexes are in bounds if tol.strict: assert faces.max() < len(v) if vn is not None and np.shape(faces_norm) == faces.shape: # do the crazy unmerging logic for split indices new_faces, mask_v, mask_vn = unmerge_faces( faces, faces_norm, maintain_faces=maintain_order) else: # generate the mask so we only include # referenced vertices in every new mesh mask_v = np.zeros(len(v), dtype=bool) mask_v[faces] = True # reconstruct the faces with the new vertex indices inverse = np.zeros(len(v), dtype=np.int64) inverse[mask_v] = np.arange(mask_v.sum()) new_faces = inverse[faces] # no normals mask_vn = None # start with vertices and faces mesh.update({'faces': new_faces, 'vertices': v[mask_v].copy()}) # if colors and normals are OK save them if vc is not None: try: # may fail on a malformed color mask mesh['vertex_colors'] = vc[mask_v] except BaseException: log.debug('failed to load vertex_colors', exc_info=True) if mask_vn is not None: try: # may fail on a malformed mask normals = vn[mask_vn] if normals.shape != mesh['vertices'].shape: raise ValueError('incorrect normals {} != {}'.format( str(normals.shape), str(mesh['vertices'].shape))) mesh['vertex_normals'] = normals except BaseException: log.debug('failed to load vertex_normals', exc_info=True) visual = None if material in materials: # use the material with the UV coordinates visual = TextureVisuals( uv=uv, material=materials[material]) elif uv is not None and len(uv) == len(mesh['vertices']): # create a texture with an empty materials visual = TextureVisuals(uv=uv) elif material is not None: # case where material is specified but not available log.debug('specified material ({}) not loaded!'.format( material)) # assign the visual mesh['visual'] = visual # store geometry by name geometry[name] = mesh if len(geometry) == 1: # TODO : should this be removed to always return a scene? return next(iter(geometry.values())) # add an identity transform for every geometry graph = [{'geometry': k, 'frame_to': k} for k in geometry.keys()] # convert to scene kwargs result = {'geometry': geometry, 'graph': graph} return result def parse_mtl(mtl, resolver=None): """ Parse a loaded MTL file. Parameters ------------- mtl : str or bytes Data from an MTL file resolver : trimesh.Resolver Fetch assets by name from file system, web, or other Returns ------------ mtllibs : list of dict Each dict has keys: newmtl, map_Kd, Kd """ # decode bytes into string if necessary mtl = util.decode_text(mtl) # current material material = None # materials referenced by name materials = {} # use universal newline splitting lines = str.splitlines(str(mtl).strip()) # remap OBJ property names to kwargs for SimpleMaterial mapped = {'kd': 'diffuse', 'ka': 'ambient', 'ks': 'specular', 'ns': 'glossiness'} for line in lines: # split by white space split = line.strip().split() # needs to be at least two values if len(split) <= 1: continue # the first value is the parameter name key = split[0].lower() # start a new material if key == 'newmtl': # material name extracted from line like: # newmtl material_0 if material is not None: # save the old material by old name and remove key materials[material.pop('newmtl')] = material # start a fresh new material material = {'newmtl': ' '.join(split[1:])} elif key == 'map_kd': # represents the file name of the texture image index = line.lower().index('map_kd') + 6 file_name = line[index:].strip() try: file_data = resolver.get(file_name) # load the bytes into a PIL image # an image file name material['image'] = Image.open( util.wrap_as_stream(file_data)) except BaseException: log.debug('failed to load image', exc_info=True) elif key in mapped.keys(): try: # diffuse, ambient, and specular float RGB value = [float(x) for x in split[1:]] # if there is only one value return that if len(value) == 1: value = value[0] # store the key by mapped value material[mapped[key]] = value # also store key by OBJ name material[key] = value except BaseException: log.debug('failed to convert color!', exc_info=True) # pass everything as kwargs to material constructor elif material is not None: # save any other unspecified keys material[key] = split[1:] # reached EOF so save any existing materials if material: materials[material.pop('newmtl')] = material return materials def _parse_faces_vectorized(array, columns, sample_line): """ Parse loaded homogeneous (tri/quad) face data in a vectorized manner. Parameters ------------ array : (n,) int Indices in order columns : int Number of columns in the file sample_line : str A single line so we can assess the ordering Returns -------------- faces : (n, d) int Faces in space faces_tex : (n, d) int or None Texture for each vertex in face faces_norm : (n, d) int or None Normal index for each vertex in face """ # reshape to columns array = array.reshape((-1, columns)) # how many elements are in the first line of faces # i.e '13/1/13 14/1/14 2/1/2 1/2/1' is 4 group_count = len(sample_line.strip().split()) # how many elements are there for each vertex reference # i.e. '12/1/13' is 3 per_ref = int(columns / group_count) # create an index mask we can use to slice vertex references index = np.arange(group_count) * per_ref # slice the faces out of the blob array faces = array[:, index] # TODO: probably need to support 8 and 12 columns for quads # or do something more general faces_tex, faces_norm = None, None if columns == 6: # if we have two values per vertex the second # one is index of texture coordinate (`vt`) # count how many delimiters are in the first face line # to see if our second value is texture or normals # do splitting to clip off leading/trailing slashes count = ''.join(i.strip('/') for i in sample_line.split()).count('/') if count == columns: # case where each face line looks like: # ' 75//139 76//141 77//141' # which is vertex/nothing/normal faces_norm = array[:, index + 1] elif count == int(columns / 2): # case where each face line looks like: # '75/139 76/141 77/141' # which is vertex/texture faces_tex = array[:, index + 1] else: log.debug('face lines are weird: {}'.format( sample_line)) elif columns == 9: # if we have three values per vertex # second value is always texture faces_tex = array[:, index + 1] # third value is reference to vertex normal (`vn`) faces_norm = array[:, index + 2] return faces, faces_tex, faces_norm def _parse_faces_fallback(lines): """ Use a slow but more flexible looping method to process face lines as a fallback option to faster vectorized methods. Parameters ------------- lines : (n,) str List of lines with face information Returns ------------- faces : (m, 3) int Clean numpy array of face triangles """ # collect vertex, texture, and vertex normal indexes v, vt, vn = [], [], [] # loop through every line starting with a face for line in lines: # remove leading newlines then # take first bit before newline then split by whitespace split = line.strip().split('\n')[0].split() # split into: ['76/558/76', '498/265/498', '456/267/456'] len_split = len(split) if len_split == 3: pass elif len_split == 4: # triangulate quad face split = [split[0], split[1], split[2], split[2], split[3], split[0]] elif len_split > 4: # triangulate polygon as a triangles fan collect = [] # we need a flat list so append inside # a list comprehension collect_append = collect.append [[collect_append(split[0]), collect_append(split[i + 1]), collect_append(split[i + 2])] for i in range(len(split) - 2)] split = collect else: log.debug( 'face needs more values 3>{} skipping!'.format( len(split))) continue # f is like: '76/558/76' for f in split: # vertex, vertex texture, vertex normal split = f.split('/') # we always have a vertex reference v.append(int(split[0])) # faster to try/except than check in loop try: vt.append(int(split[1])) except BaseException: pass try: # vertex normal is the third index vn.append(int(split[2])) except BaseException: pass # shape into triangles and switch to 0-indexed # 0-indexing only applies to positive indices faces = np.array(v, dtype=np.int64).reshape((-1, 3)) faces[faces > 0] -= 1 faces_tex, normals = None, None if len(vt) == len(v): faces_tex = np.array(vt, dtype=np.int64).reshape((-1, 3)) faces_tex[faces_tex > 0] -= 1 if len(vn) == len(v): normals = np.array(vn, dtype=np.int64).reshape((-1, 3)) normals[normals > 0] -= 1 return faces, faces_tex, normals def _parse_vertices(text): """ Parse raw OBJ text into vertices, vertex normals, vertex colors, and vertex textures. Parameters ------------- text : str Full text of an OBJ file Returns ------------- v : (n, 3) float Vertices in space vn : (m, 3) float or None Vertex normals vt : (p, 2) float or None Vertex texture coordinates vc : (n, 3) float or None Per-vertex color """ # the first position of a vertex in the text blob # we only really need to search from the start of the file # up to the location of out our first vertex but we # are going to use this check for "do we have texture" # determination later so search the whole stupid file starts = {k: text.find('\n{} '.format(k)) for k in ['v', 'vt', 'vn']} # no valid values so exit early if not any(v >= 0 for v in starts.values()): return None, None, None, None # find the last position of each valid value ends = {k: text.find( '\n', text.rfind('\n{} '.format(k)) + 2 + len(k)) for k, v in starts.items() if v >= 0} # take the first and last position of any vertex property start = min(s for s in starts.values() if s >= 0) end = max(e for e in ends.values() if e >= 0) # get the chunk of test that contains vertex data chunk = text[start:end].replace('+e', 'e').replace('-e', 'e') # get the clean-ish data from the file as python lists data = {k: [i.split('\n', 1)[0] for i in chunk.split('\n{} '.format(k))[1:]] for k, v in starts.items() if v >= 0} # count the number of data values per row on a sample row per_row = {k: len(v[0].split()) for k, v in data.items()} # convert data values into numpy arrays result = defaultdict(lambda: None) for k, value in data.items(): # use joining and fromstring to get as numpy array array = np.fromstring( ' '.join(value), sep=' ', dtype=np.float64) # what should our shape be shape = (len(value), per_row[k]) # check shape of flat data if len(array) == np.product(shape): # we have a nice 2D array result[k] = array.reshape(shape) else: # try to recover with a slightly more expensive loop count = per_row[k] try: # try to get result through reshaping result[k] = np.fromstring( ' '.join(i.split()[:count] for i in value), sep=' ', dtype=np.float64).reshape(shape) except BaseException: pass # vertices v = result['v'] # vertex colors are stored next to vertices vc = None if v is not None and v.shape[1] >= 6: # vertex colors are stored after vertices v, vc = v[:, :3], v[:, 3:6] elif v is not None and v.shape[1] > 3: # we got a lot of something unknowable v = v[:, :3] # vertex texture or None vt = result['vt'] if vt is not None: # sometimes UV coordinates come in as UVW vt = vt[:, :2] # vertex normals or None vn = result['vn'] # check will generally only be run in unit tests # so we are allowed to do things that are slow if tol.strict: # check to make sure our subsetting # didn't miss any vertices or data assert len(v) == text.count('\nv ') # make sure optional data matches file too if vn is not None: assert len(vn) == text.count('\nvn ') if vt is not None: assert len(vt) == text.count('\nvt ') return v, vn, vt, vc def _group_by_material(face_tuples): """ For chunks of faces split by material group the chunks that share the same material. Parameters ------------ face_tuples : (n,) list of (material, obj, chunk) The data containing faces Returns ------------ grouped : (m,) list of (material, obj, chunk) Grouped by material """ # store the chunks grouped by material grouped = defaultdict(lambda: ['', '', []]) # loop through existring for material, obj, chunk in face_tuples: grouped[material][0] = material grouped[material][1] = obj # don't do a million string concatenations in loop grouped[material][2].append(chunk) # go back and do a join to make a single string for k in grouped.keys(): grouped[k][2] = '\n'.join(grouped[k][2]) # return as list return list(grouped.values()) def _preprocess_faces(text): """ Pre-Process Face Text Rather than looking at each line in a loop we're going to split lines by directives which indicate a new mesh, specifically 'usemtl' and 'o' keys search for materials, objects, faces, or groups Parameters ------------ text : str Raw file Returns ------------ triple : (n, 3) tuple Tuples of (material, object, data-chunk) """ # see which chunk is relevant starters = ['\nusemtl ', '\no ', '\nf ', '\ng ', '\ns '] f_start = len(text) # first index of material, object, face, group, or smoother for st in starters: search = text.find(st, 0, f_start) # if not contained find will return -1 if search < 0: continue # subtract the length of the key from the position # to make sure it's included in the slice of text if search < f_start: f_start = search # index in blob of the newline after the last face f_end = text.find('\n', text.rfind('\nf ') + 3) # get the chunk of the file that has face information if f_end >= 0: # clip to the newline after the last face f_chunk = text[f_start:f_end] else: # no newline after last face f_chunk = text[f_start:] if tol.strict: # check to make sure our subsetting didn't miss any faces assert f_chunk.count('\nf ') == text.count('\nf ') # two things cause new meshes to be created: # objects and materials # re.finditer was faster than find in a loop # find the index of every material change idx_mtl = np.array([m.start(0) for m in re.finditer( 'usemtl ', f_chunk)], dtype=int) # find the index of every new object idx_obj = np.array([m.start(0) for m in re.finditer( '\no ', f_chunk)], dtype=int) # find all the indexes where we want to split splits = np.unique(np.concatenate(( [0, len(f_chunk)], idx_mtl, idx_obj))) # track the current material and object ID current_obj = None current_mtl = None # store (material, object, face lines) face_tuples = [] for start, end in zip(splits[:-1], splits[1:]): # ensure there's always a trailing newline chunk = f_chunk[start:end].strip() + '\n' if chunk.startswith('o '): current_obj, chunk = chunk.split('\n', 1) current_obj = current_obj[2:].strip() elif chunk.startswith('usemtl'): current_mtl, chunk = chunk.split('\n', 1) current_mtl = current_mtl[6:].strip() if 'f ' in chunk: face_tuples.append((current_mtl, current_obj, chunk)) return face_tuples def export_obj(mesh, include_normals=True, include_color=True, include_texture=True, return_texture=False, write_texture=True, resolver=None, digits=8, mtl_name=None, header='https://github.com/mikedh/trimesh'): """ Export a mesh as a Wavefront OBJ file. TODO: scenes with textured meshes Parameters ----------- mesh : trimesh.Trimesh Mesh to be exported include_normals : bool Include vertex normals in export include_color : bool Include vertex color in export include_texture : bool Include `vt` texture in file text return_texture : bool If True, return a dict with texture files write_texture : bool If True and a writable resolver is passed write the referenced texture files with resolver resolver : None or trimesh.resolvers.Resolver Resolver which can write referenced text objects digits : int Number of digits to include for floating point mtl_name : None or str If passed, the file name of the MTL file. header : str or None Header string for top of file or None for no header. Returns ----------- export : str OBJ format output texture : dict Contains files that need to be saved in the same directory as the exported mesh: {file name : bytes} """ # store the multiple options for formatting # vertex indexes for faces face_formats = {('v',): '{}', ('v', 'vn'): '{}//{}', ('v', 'vt'): '{}/{}', ('v', 'vn', 'vt'): '{}/{}/{}'} # check the input if util.is_instance_named(mesh, 'Trimesh'): meshes = [mesh] elif util.is_instance_named(mesh, 'Scene'): meshes = mesh.dump() elif util.is_instance_named(mesh, 'PointCloud'): meshes = [mesh] else: raise ValueError('must be Trimesh or Scene!') # collect lines to export objects = deque([]) # keep track of the number of each export element counts = {'v': 0, 'vn': 0, 'vt': 0} # collect materials as we go materials = {} materials_name = set() for current in meshes: # we are going to reference face_formats with this face_type = ['v'] # OBJ includes vertex color as RGB elements on the same line if (include_color and current.visual.kind in ['vertex', 'face'] and len(current.visual.vertex_colors)): # create a stacked blob with position and color v_blob = np.column_stack(( current.vertices, to_float(current.visual.vertex_colors[:, :3]))) else: # otherwise just export vertices v_blob = current.vertices # add the first vertex key and convert the array # add the vertices export = deque( ['v ' + util.array_to_string( v_blob, col_delim=' ', row_delim='\nv ', digits=digits)]) # only include vertex normals if they're already stored if include_normals and current._cache.cache.get('vertex_normals') is not None: try: converted = util.array_to_string( current.vertex_normals, col_delim=' ', row_delim='\nvn ', digits=digits) # if vertex normals are stored in cache export them face_type.append('vn') export.append('vn ' + converted) except BaseException: log.debug('failed to convert vertex normals', exc_info=True) # collect materials into a dict if include_texture and hasattr(current.visual, 'uv'): try: # get a SimpleMaterial material = current.visual.material if hasattr(material, 'to_simple'): material = material.to_simple() # hash the material to avoid duplicates hashed = hash(material) if hashed not in materials: # get a unique name for the material name = util.unique_name( material.name, materials_name) # add the name to our collection materials_name.add(name) # convert material to an OBJ MTL materials[hashed] = material.to_obj( name=name) # get the name of the current material as-stored tex_name = materials[hashed][1] # export the UV coordinates if len(np.shape(getattr(current.visual, 'uv', None))) == 2: converted = util.array_to_string( current.visual.uv, col_delim=' ', row_delim='\nvt ', digits=digits) # if vertex texture exists and is the right shape face_type.append('vt') # add the uv coordinates export.append('vt ' + converted) # add the directive to use the exported material export.appendleft('usemtl {}'.format(tex_name)) except BaseException: log.debug('failed to convert UV coordinates', exc_info=True) # the format for a single vertex reference of a face face_format = face_formats[tuple(face_type)] # add the exported faces to the export if available if hasattr(current, 'faces'): export.append('f ' + util.array_to_string( current.faces + 1 + counts['v'], col_delim=' ', row_delim='\nf ', value_format=face_format)) # offset our vertex position counts['v'] += len(current.vertices) # add object name if found in metadata if 'name' in current.metadata: export.appendleft( '\no {}'.format(current.metadata['name'])) # add this object objects.append('\n'.join(export)) # combine materials if len(materials) > 0: # collect text for a single mtllib file mtl_lib = [] # collect files like images to write mtl_data = {} # now loop through: keys are garbage hash # values are (data, name) for data, name in materials.values(): for file_name, file_data in data.items(): if file_name.lower().endswith('.mtl'): # collect mtl lines into single file mtl_lib.append(file_data) elif file_name not in mtl_data: # things like images mtl_data[file_name] = file_data else: log.warning('not writing {}'.format(file_name)) if mtl_name is None: # if no name passed set a default mtl_name = 'material.mtl' # prepend a header to the MTL text if requested if header is not None: prepend = '# {}\n\n'.format(header).encode('utf-8') else: prepend = b'' # save the material data mtl_data[mtl_name] = prepend + b'\n\n'.join(mtl_lib) # add the reference to the MTL file objects.appendleft('mtllib {}'.format(mtl_name)) if header is not None: # add a created-with header to the top of the file objects.appendleft('# {}'.format(header)) # combine elements into a single string text = '\n'.join(objects) # if we have a resolver and have asked to write texture if write_texture and resolver is not None and len(materials) > 0: # not all resolvers have a write method [resolver.write(k, v) for k, v in mtl_data.items()] # if we exported texture it changes returned values if return_texture: return text, mtl_data return text _obj_loaders = {'obj': load_obj}