import os
import re
from collections import defaultdict, deque

import numpy as np

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 ExceptionWrapper

    Image = ExceptionWrapper(E)

from .. import util
from ..constants import log, tol
from ..resolvers import ResolverLike
from ..typed import Dict, Loadable, Optional
from ..visual.color import to_float
from ..visual.material import SimpleMaterial
from ..visual.texture import TextureVisuals, unmerge_faces


def load_obj(
    file_obj: Loadable,
    resolver: Optional[ResolverLike] = None,
    group_material: bool = True,
    skip_materials: bool = False,
    maintain_order: bool = False,
    metadata: Optional[Dict] = None,
    split_objects: bool = False,
    split_groups: bool = False,
    **kwargs,
) -> Dict:
    """
    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
      Don't load any materials.
    maintain_order
      Make the strongest attempt possible to not reorder faces
      or vertices which may result in visual artifacts and other
      odd behavior. The OBJ data structure is quite different than
      the "flat matching array" used by Trimesh and GLTF so this may
      not be completely possible.
    split_objects
      Whenever the loader encounters an `o` directive in the OBJ
      file, split the loaded result into a new Trimesh object.
    split_groups
        Whenever the loader encounters a `g` directive in the OBJ
        file, split the loaded result into a new Trimesh object.

    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 (OSError, TypeError):
            # usually the resolver couldn't find the asset
            log.debug(f"unable to load materials from: {mtl_path}")
        except BaseException:
            # something else happened so log a warning
            log.debug(f"unable to load materials from: {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, split_objects, split_groups)

    # 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 or split_objects or split_groups:
        face_tuples = _group_by(face_tuples, group_material, split_objects, split_groups)

    # 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, current_group, 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].strip()
            for i in re.split("^f", chunk, flags=re.MULTILINE)[1:]
        ]

        # check every face for mixed tri-quad-ngon
        columns = len(face_lines[0].replace("/", " ").split())
        flat_array = all(columns == len(f.replace("/", " ").split()) for f in face_lines)

        # make sure we have the right number of values for vectorized
        if flat_array:
            # the fastest way to get to a numpy array
            # processes the whole string at once into a 1D array
            array = np.fromstring(
                " ".join(face_lines).replace("/", " "), sep=" ", dtype=np.int64
            )
            # also wavefront is 1-indexed (vs 0-indexed) so offset
            # only applies to positive indices
            array[array > 0] -= 1

            # 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)

        # build name from components
        name_parts = []
        if split_objects and current_object is not None:
            name_parts.append(current_object)
        if split_groups and current_group is not None:
            name_parts.append(current_group)
        if group_material and len(materials) > 1 and material is not None:
            name_parts.append(str(material))

        # join parts or fall back to defaults
        if name_parts:
            name = "_".join(name_parts)
        elif current_object is not None:
            name = current_object
        else:
            # try to use the file name from the resolver
            # or file object if possible before defaulting
            name = next(
                i
                for i in (
                    getattr(resolver, "file_name", None),
                    getattr(file_obj, "name", None),
                    "geometry",
                )
                if i is not None
            )

        # ensure the name is always unique in the geometry dict
        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:
                # face_tex is None and
                # generate the mask so we only include
                # referenced vertices in every new mesh
                if maintain_order:
                    mask_v = np.ones(len(v), dtype=bool)
                else:
                    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(f"specified material ({material})  not loaded!")
        # assign the visual
        mesh["visual"] = visual
        # store geometry by name
        geometry[name] = mesh

    # add an identity transform for every geometry
    graph = [{"geometry": k, "frame_to": k} for k in geometry.keys()]

    # convert to scene kwargs
    return {"geometry": geometry, "graph": graph}


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["name"]] = material
            # start a fresh new material
            # do we really want to support material names with whitespace?
            material = {"name": " ".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))
                # also store the original map_kd file name
                material["image"].info["file_path"] = os.path.abspath(
                    os.path.join(getattr(resolver, "parent", ""), file_name)
                )

            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]
                if material is not None:
                    # 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["name"]] = 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 == group_count * 2:
        # 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(f"face lines are weird: {sample_line}")
    elif columns == group_count * 3:
        # 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(f"face needs more values 3>{len(split)} skipping!")
            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(f"\n{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(f"\n{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(f"\n{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.prod(shape):
            # we have a nice 2D array
            result[k] = array.reshape(shape)
        else:
            # we don't have a nice (n, d) array so fall back to a slow loop
            # this is where mixed "some of the values but not all have vertex colors"
            # problem is handled.
            lines = []
            [[lines.append(v.strip().split()) for v in str.splitlines(i)] for i in value]
            # we need to make a 2D array so clip it to the shortest array
            count = min(len(L) for L in lines)
            # make a numpy array out of the cleaned up line data
            result[k] = np.array([L[:count] for L in lines], dtype=np.float64)

    # 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(face_tuples, use_mtl: bool, use_obj: bool, use_group: bool):
    """
    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
    use_mtl
      Group tuples by `usemtl` commands
    use_obj
      Group tuples by `o` commands
    use_group
      Group tuples by `g` commands

    Returns
    ------------
    grouped : (m,) list of tuples containing:
              `(material name, objectname, group name, raw chunk)`
    """

    # store the chunks grouped by material
    grouped = defaultdict(lambda: ["", "", "", []])
    # loop through existring
    for material, obj, group, chunk in face_tuples:
        # tuple key for the dict
        key = (
            material if use_mtl else None,
            obj if use_obj else None,
            group if use_group else None,
        )
        grouped[key][0] = material
        grouped[key][1] = obj
        grouped[key][2] = group
        # don't do a million string concatenations in loop
        grouped[key][3].append(chunk)
    # go back and do a join to make a single string
    for key in grouped.keys():
        grouped[key][3] = "\n".join(grouped[key][3])
    # return as list
    return list(grouped.values())


def _preprocess_faces(text, use_obj=False, use_groups=False):
    """
    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', 'o', and 'g' 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
    split_idxs = [[0, len(f_chunk)], idx_mtl]

    # NOTE: This used to split objects on every run, but now it does not
    if use_obj:
        split_idxs.append(
            np.array([m.start(0) for m in re.finditer("\no ", f_chunk)], dtype=int)
        )

    if use_groups:
        split_idxs.append(
            np.array([m.start(0) for m in re.finditer("\ng ", f_chunk)], dtype=int)
        )

    # find all the indexes where we want to split
    splits = np.unique(np.concatenate(tuple(split_idxs)))

    # track the current material and object ID
    current_obj = None
    current_mtl = None
    current_group = None
    # store (material, object, group, 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()
        # Discard the g tag line in the list of faces
        elif chunk.startswith("g "):
            current_group, chunk = chunk.split("\n", 1)
            current_group = current_group[2:].strip()
        # If we have an f at the beginning of a line
        # then add it to the list of faces chunks
        if chunk.startswith("f ") or "\nf" in chunk:
            face_tuples.append((current_mtl, current_obj, current_group, chunk))
    return face_tuples


def export_obj(
    mesh,
    include_normals=None,
    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 : Optional[bool]
      Include vertex normals in export. If None
      will only be included if vertex normals are in cache.
    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
                )
            ]
        )

        # if include_normals is None then
        # only include if they're already stored
        if include_normals is None:
            include_normals = "vertex_normals" in current._cache.cache

        if include_normals:
            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(f"usemtl {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))

    # collect files like images to write
    mtl_data = {}
    # combine materials
    if len(materials) > 0:
        # collect text for a single mtllib file
        mtl_lib = []
        # now loop through: keys are garbage hash
        # values are (data, name)
        for data, _ 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(f"not writing {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 = f"# {header}\n\n".encode()
        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(f"mtllib {mtl_name}")

    if header is not None:
        # add a created-with header to the top of the file
        objects.appendleft(f"# {header}")

    # add a trailing newline
    objects.append("\n")

    # 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}
