# coding=utf-8
"""A class that supports the import and export of OBJ data to/from ladybug_geometry.
"""
import os
try:
from itertools import izip as zip # python 2
writemode = 'wb'
except ImportError:
writemode = 'w' # python 3
from ladybug_geometry.geometry2d.pointvector import Point2D
from ladybug_geometry.geometry3d.pointvector import Vector3D, Point3D
[docs]
class OBJ(object):
"""A class that supports the import and export of OBJ data to/from ladybug_geometry.
Note that ladybug_geometry Mesh3D can be easily created from this OBJ by
taking the vertices and normals.
Args:
vertices: A list or tuple of Point3D objects for vertices.
faces: A list of tuples with each tuple having either 3 or 4 integers.
These integers correspond to indices within the list of vertices.
vertex_texture_map: An optional list or tuple of Point2D that align with the
vertices input. All coordinate values of the Point2D should be between
0 and 1 and are intended to map to the XY system of images to be mapped
onto the OBJ mesh. If None, the OBJ file is written without
textures. (Default: None).
vertex_normals: An optional list or tuple of Vector3D that align with the
vertices input and describe the normal vector to be used at each vertex.
If None, the OBJ file is written without normals. (Default: None).
vertex_colors: An optional list of colors that align with the vertices input.
Note that these are written into the OBJ alongside the vertex
coordinates separately from the texture map. Not all programs support
importing OBJs with this color information but Rhino does. (Default: None).
material_structure: A list of tuples where each tuple contains two elements.
The first is the identifier of a material that is used in the OBJ and
the second is the index of the face where the application of the new
material begins. If None, everything will be assumed to have the
same diffuse material. (Default: None).
Properties:
* vertices
* faces
* vertex_texture_map
* vertex_normals
* vertex_colors
* material_structure
"""
__slots__ = (
'_vertices', '_faces', '_vertex_texture_map', '_vertex_normals',
'_vertex_colors', '_material_structure'
)
def __init__(
self, vertices, faces, vertex_texture_map=None, vertex_normals=None,
vertex_colors=None, material_structure=None
):
self._vertices = self._check_vertices_input(vertices)
self._faces = self._check_faces_input(faces)
self.vertex_texture_map = vertex_texture_map
self.vertex_normals = vertex_normals
self.vertex_colors = vertex_colors
self.material_structure = material_structure
[docs]
@classmethod
def from_file(cls, file_path):
"""Create an OBJ object from a .obj file.
Args:
file_path: Path to an OBJ file as a text string. Note that, if the file
includes texture mapping coordinates or vertex normals, the number
of texture coordinates and normals must align with the number of
vertices to be importable. Nearly all OBJ files follow this standard.
If any of the OBJ mesh faces contain more than 4 vertices, only
the first 4 vertices will be counted.
"""
vertices, faces, vertex_texture_map, vertex_normals, vertex_colors = \
[], [], [], [], []
mat_struct = []
with open(file_path, 'r') as fp:
for line in fp:
if line.startswith('#'):
continue
wds = line.split()
if len(wds) > 0:
first_word = wds[0]
if first_word == 'v': # start of a new vertex
vert = Point3D(float(wds[1]), float(wds[2]), float(wds[3]))
vertices.append(vert)
if len(wds) > 4:
vertex_colors.append(tuple(wds[4:]))
elif first_word == 'f': # start of a new face
face = []
for fv in wds[1:]:
face.append(int(fv.split('/')[0]) - 1)
if len(face) > 4: # truncate for compatibility with Mesh3D
face = face[:4]
faces.append(tuple(face))
elif first_word == 'vn': # start of a new vertex normal
norm = Vector3D(float(wds[1]), float(wds[2]), float(wds[3]))
vertex_normals.append(norm)
elif first_word == 'vt': # start of a new texture coordinate
texture = Point2D(float(wds[1]), float(wds[2]))
vertex_texture_map.append(texture)
elif first_word == 'usemtl': # start of a new material application
mat_struct.append((wds[1], len(faces)))
return cls(vertices, faces, vertex_texture_map, vertex_normals,
vertex_colors, mat_struct)
[docs]
@classmethod
def from_mesh3d(cls, mesh, include_colors=True, include_normals=False):
"""Create an OBJ object from a ladybug_geometry Mesh3D.
If colors are specified on the Mesh3D, they will be correctly transferred
to the resulting OBJ object as long as include_colors is True.
Args:
mesh: A ladybug_geometry Mesh3D object to be converted to an OBJ object.
include_colors: Boolean to note whether the Mesh3D colors should be
transferred to the OBJ object. (Default: True).
include_normals: Boolean to note whether the vertex normals should be
included in the resulting OBJ object. (Default: False).
"""
if include_colors and mesh.is_color_by_face:
# we need to duplicate vertices to preserve colors
vertices, faces, colors = [], [], []
v_ct = 0
for face_verts, col in zip(mesh.face_vertices, mesh.colors):
vertices.extend(face_verts)
if len(face_verts) == 4:
faces.append((v_ct, v_ct + 1, v_ct + 2, v_ct + 3))
colors.extend([col] * 4)
v_ct += 4
else:
faces.append((v_ct, v_ct + 1, v_ct + 2))
colors.extend([col] * 3)
v_ct += 3
if include_normals:
msh_norms = mesh.vertex_normals
vert_normals = []
for face in mesh.faces:
for fi in face:
vert_normals.append(msh_norms[fi])
return cls(vertices, faces, vertex_normals=msh_norms,
vertex_colors=colors)
return cls(vertices, faces, vertex_colors=colors)
vertex_colors = mesh.colors if include_colors else None
if include_normals:
return cls(mesh.vertices, mesh.faces, vertex_normals=mesh.vertex_normals,
vertex_colors=vertex_colors)
return cls(mesh.vertices, mesh.faces, vertex_colors=vertex_colors)
[docs]
@classmethod
def from_mesh3ds(cls, meshes, material_ids=None, include_normals=False):
"""Create an OBJ object from a list of ladybug_geometry Mesh3D.
Mesh3D colors are ignored when using this method with the assumption that
materials are used to specify how the meshes should be rendered.
Args:
meshes: A list of ladybug_geometry Mesh3D objects to be converted
into an OBJ object.
material_ids: An optional list of strings that aligns with the input
meshes and denote materials assigned to each mesh. This list of
material IDs will be automatically converted into an efficient
material_structure for the OBJ object where materials used for
multiple meshes only include one reference to the material. If
None, the OBJ will have no material structure. (Default: None).
include_normals: Boolean to note whether the vertex normals should be
included in the resulting OBJ object. (Default: False).
"""
# sort the meshes by material ID to ensure efficient material structure
if material_ids is not None:
assert len(material_ids) == len(meshes), 'Length of OBJ material_ids ({}) ' \
'does not match the length of meshes ({}).'.format(
len(material_ids), len(meshes))
meshes = [x for _, x in sorted(zip(material_ids, meshes))]
material_ids = sorted(material_ids)
# gather all vertices, faces, and (optionally) normals together
vertices, faces, normals, mat_struct = [], [], [], []
v_count = 0
if material_ids is not None:
last_mat = None
for mesh, mat_id in zip(meshes, material_ids):
if mat_id != last_mat:
mat_struct.append((mat_id, len(faces)))
last_mat = mat_id
vertices.extend(mesh.vertices)
if include_normals:
normals.extend(mesh.vertex_normals)
if v_count == 0:
faces.extend(mesh.faces)
else:
for f in mesh.faces:
faces.append(tuple(fi + v_count for fi in f))
v_count += len(mesh.vertices)
else:
for mesh in meshes:
vertices.extend(mesh.vertices)
if include_normals:
normals.extend(mesh.vertex_normals)
if v_count == 0:
faces.extend(mesh.faces)
else:
for f in mesh.faces:
faces.append(tuple(fi + v_count for fi in f))
v_count += len(mesh.vertices)
return cls(
vertices, faces, vertex_normals=normals, material_structure=mat_struct)
@property
def vertices(self):
"""Tuple of Point3D for all vertices in the OBJ."""
return self._vertices
@property
def faces(self):
"""Tuple of tuples for all faces in the OBJ."""
return self._faces
@property
def vertex_texture_map(self):
"""Get or set a tuple of Point2D for texture image coordinates for each vertex.
Will be None if no texture map is assigned.
"""
return self._vertex_texture_map
@vertex_texture_map.setter
def vertex_texture_map(self, value):
if value is not None:
assert isinstance(value, (list, tuple)), 'vertex_texture_map should be ' \
'a list or tuple. Got {}'.format(type(value))
if isinstance(value, list):
value = tuple(value)
if len(value) == 0:
value = None
elif len(value) != len(self.vertices):
raise ValueError(
'Number of items in vertex_texture_map ({}) does not match number'
'of OBJ vertices ({}).'.format(len(value), len(self.vertices)))
else:
for vert in value:
assert isinstance(vert, Point2D), 'Expected Point2D for OBJ ' \
'vertex texture. Got {}.'.format(type(vert))
self._vertex_texture_map = value
@property
def vertex_normals(self):
"""Get or set a tuple of Vector3D for vertex normals.
Will be None if no vertex normals are assigned.
"""
return self._vertex_normals
@vertex_normals.setter
def vertex_normals(self, value):
if value is not None:
assert isinstance(value, (list, tuple)), \
'vertex_normals should be a list or tuple. Got {}'.format(type(value))
if isinstance(value, list):
value = tuple(value)
if len(value) == 0:
value = None
elif len(value) != len(self.vertices):
raise ValueError(
'Number of OBJ vertex_normals ({}) does not match the number of'
' OBJ vertices ({}).'.format(len(value), len(self.vertices)))
else:
for norm in value:
assert isinstance(norm, Vector3D), 'Expected Vector3D for OBJ ' \
'vertex normal. Got {}.'.format(type(norm))
self._vertex_normals = value
@property
def vertex_colors(self):
"""Get or set a list of colors for the OBJ. Will be None if no colors assigned.
"""
return self._vertex_colors
@vertex_colors.setter
def vertex_colors(self, value):
if value is not None:
assert isinstance(value, (list, tuple)), \
'vertex_normals should be a list or tuple. Got {}'.format(type(value))
if isinstance(value, list):
value = tuple(value)
if len(value) == 0:
value = None
elif len(value) != len(self.vertices):
raise ValueError(
'Number of OBJ vertex_normals ({}) does not match the number of'
' OBJ vertices ({}).'.format(len(value), len(self.vertices)))
self._vertex_colors = value
@property
def material_structure(self):
"""Get or set a tuple of tuples that specify the material structure of the obj.
Each sub-tuple contains two elements. The first is the identifier of a
material that is used in the OBJ and the second is the index of the face
where the application of the new material begins. If None, everything
will be assumed to have the same diffuse material.
"""
return self._material_structure
@material_structure.setter
def material_structure(self, value):
if value is not None:
assert isinstance(value, (list, tuple)), \
'vertex_normals should be a list or tuple. Got {}'.format(type(value))
if len(value) == 0:
value = None
else:
for mt in value:
assert isinstance(mt, tuple), 'Expected tuple for OBJ material ' \
'structure. Got {}.'.format(type(mt))
assert len(mt) == 2, 'OBJ material structure must have 2 items. ' \
'Got {}.'.format(len(mt))
assert isinstance(mt[0], str), 'Expected String for OBJ material ' \
'identifier. Got {}.'.format(type(mt[0]))
try:
self._faces[mt[1]]
except IndexError:
raise IndexError(
'OBJ material index {} does not correspond to any face. '
'There are {} faces in the mesh.'.format(
mt[1], len(self._faces)))
except TypeError:
raise TypeError(
'OBJ material must use integers to reference faces. '
'Got {}.'.format(type(mt[1])))
value = sorted(value, key=lambda x: x[1])
value = tuple(value)
self._material_structure = value
[docs]
def to_file(self, folder, name, triangulate_quads=False, include_mtl=False):
"""Write the OBJ object to an ASCII text file.
Args:
folder: A text string for the directory where the OBJ will be written.
name: A text string for the name of the OBJ file. Note that, if an image
texture is meant to be assigned to this OBJ, the image should have
the same name as the one input here except with the .mtl extension
instead of the .obj extension.
triangulate_quads: Boolean to note whether quad faces should be
triangulated upon export to OBJ. This may be needed for certain
software platforms that require the mesh to be composed entirely
of triangles (eg. Radiance). (Default: False).
include_mtl: Boolean to note whether an .mtl file should be automatically
generated from the material structure written next to the .obj
file in the output folder. All materials in the mtl file will
be diffuse white, with the assumption that these will be
customized later. (Default: False).
"""
# set up a name and folder
file_name = name if name.lower().endswith('.obj') else '{}.obj'.format(name)
obj_file = os.path.join(folder, file_name)
mtl_file = '{}.mtl'.format(name) if not name.lower().endswith('.obj') else \
'{}.mtl'.format(name[:-4])
# write everything into the OBJ file
with open(obj_file, writemode) as outfile:
# add a comment at the top to note where the OBJ is written from
outfile.write('# OBJ file written by ladybug geometry\n\n')
# add material file name if include_mtl is true
if self._material_structure is not None or include_mtl:
if include_mtl:
outfile.write('mtllib ' + mtl_file + '\n')
if self._material_structure is None:
outfile.write('usemtl diffuse_0\n')
# loop through the vertices and add them to the file
if self.vertex_colors is None:
for v in self.vertices:
outfile.write('v {} {} {}\n'.format(v.x, v.y, v.z))
else: # write the vertex colors alongside the vertices
if len(self.vertex_colors[0]) > 3:
for v, c in zip(self.vertices, self.vertex_colors):
outfile.write(
'v {} {} {} {} {} {}\n'.format(
v.x, v.y, v.z, c[0], c[1], c[2])
)
else: # might be a grayscale weight
for v, c in zip(self.vertices, self.vertex_colors):
outfile.write(
'v {} {} {} {}\n'.format(v.x, v.y, v.z, ' '.join(c))
)
# loop through the texture vertices, if present, and add them to the file
if self.vertex_texture_map is not None:
for vt in self.vertex_texture_map:
outfile.write('vt {} {}\n'.format(vt.x, vt.y))
# loop through the normals, if present, and add them to the file
if self.vertex_normals is not None:
for vn in self.vertex_normals:
outfile.write('vn {} {} {}\n'.format(vn.x, vn.y, vn.z))
# triangulate the faces if requested
formatted_faces, formatted_mats = self.faces, self.material_structure
if triangulate_quads:
formatted_faces = []
if formatted_mats is None or len(formatted_mats) == 1:
for f in self.faces:
if len(f) > 3:
formatted_faces.append((f[0], f[1], f[2]))
formatted_faces.append((f[2], f[3], f[0]))
else:
formatted_faces.append(f)
else:
mat_ind = [mat[1] for mat in formatted_mats]
for i, f in enumerate(self.faces):
if len(f) > 3:
formatted_faces.append((f[0], f[1], f[2]))
formatted_faces.append((f[2], f[3], f[0]))
for j, m in enumerate(formatted_mats):
if m[1] > i:
mat_ind[j] = mat_ind[j] + 1
else:
formatted_faces.append(f)
formatted_mats = \
[(mn[0], mi) for mn, mi in zip(formatted_mats, mat_ind)]
# loop through the faces and get all lines of text for them
face_txt = []
if self.vertex_texture_map is None and self.vertex_normals is None:
for f in formatted_faces:
face_txt.append('f ' + ' '.join(str(fi + 1) for fi in f) + '\n')
else:
if self.vertex_texture_map is not None and \
self.vertex_normals is not None:
f_map = '{0}/{0}/{0}'
elif self.vertex_texture_map is None and \
self.vertex_normals is not None:
f_map = '{0}//{0}'
else:
f_map = '{0}/{0}'
for f in formatted_faces:
face_txt.append(
'f ' + ' '.join(f_map.format(fi + 1) for fi in f) + '\n'
)
# write the faces into the file with the material structure
if formatted_mats is not None: # insert the materials
for mat in reversed(formatted_mats):
face_txt.insert(mat[1], 'usemtl {}\n'.format(mat[0]))
for f_lin in face_txt:
outfile.write(f_lin)
# write the MTL file if requested
if include_mtl:
mat_struct = [('diffuse_0', 0)] if self._material_structure is None else \
self._material_structure
mtl_fp = os.path.join(folder, mtl_file)
with open(mtl_fp, writemode) as mtl_f:
mtl_f.write('# Ladybug Geometry\n')
for mat in reversed(mat_struct):
mtl_str = \
'newmtl {}\n' \
'Ka 0.0000 0.0000 0.0000\n' \
'Kd 1.0000 1.0000 1.0000\n' \
'Ks 0.0000 0.0000 0.0000\n' \
'Tf 0.0000 0.0000 0.0000\n' \
'd 1.0000\n' \
'Ns 0.0000\n'.format(mat[0])
mtl_f.write(mtl_str)
return obj_file
def _check_vertices_input(self, vertices):
"""Check the input vertices."""
if not isinstance(vertices, tuple):
vertices = tuple(vertices)
for vert in vertices:
assert isinstance(vert, Point3D), \
'Expected Point3D for OBJ vertex. Got {}.'.format(type(vert))
return vertices
def _check_faces_input(self, faces):
"""Check input faces for correct formatting."""
if not isinstance(faces, tuple):
faces = tuple(faces)
assert len(faces) > 0, 'OBJ mesh must have at least one face.'
for f in faces:
assert isinstance(f, tuple), \
'Expected tuple for Mesh face. Got {}.'.format(type(f))
assert len(f) >= 3, \
'OBJ mesh face must have 3 or more vertices. Got {}.'.format(len(f))
for ind in f:
try:
self._vertices[ind]
except IndexError:
raise IndexError(
'mesh face index {} does not correspond to any vertex. There '
'are {} vertices in the mesh.'.format(ind, len(self._vertices)))
except TypeError:
raise TypeError(
'Mesh face must use integers to reference vertices. '
'Got {}.'.format(type(ind)))
return faces
def __len__(self):
return len(self._vertices)
def __getitem__(self, key):
return self._vertices[key]
def __iter__(self):
return iter(self._vertices)
def __repr__(self):
return 'OBJ ({} vertices) ({} faces)'.format(
len(self._vertices), len(self._faces))