Files
geMoldInsight/venv/lib/python3.11/site-packages/trimesh/exchange/obj.py
T
2026-02-12 23:27:48 +08:00

1001 lines
35 KiB
Python

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,
**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 (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=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].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)
if group_material and len(materials) > 1:
name = material
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
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_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()
# Discard the g tag line in the list of faces
elif chunk.startswith("g "):
_, chunk = chunk.split("\n", 1)
# 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, 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}