Skip to content

Commit

Permalink
feat(mesh): Adding methods to create offset and height field meshes
Browse files Browse the repository at this point in the history
... and responding to comments that @mostaphaRoudsari left on the original to/from dict PR.
  • Loading branch information
chriswmackey authored and Chris Mackey committed Jun 4, 2019
1 parent 7ab91a0 commit 70330da
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 25 deletions.
38 changes: 37 additions & 1 deletion ladybug_geometry/_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
"""Base class for all Mesh objects."""
from __future__ import division

import sys
if (sys.version_info > (3, 0)): # python 3
xrange = range


class MeshBase(object):
"""Base class for all Mesh objects.
Expand All @@ -14,8 +18,28 @@ class MeshBase(object):
face_areas
area
face_centroids
vertex_connected_faces
"""

def __init__(self, vertices, faces, colors=None):
"""Initilize MeshBase.
Args:
vertices: A list or tuple of Point 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.
colors: An optional list of colors that correspond to either the faces
of the mesh or the vertices of the mesh. Default is None.
"""
self._vertices = vertices
self._check_faces_input(faces)
self._is_color_by_face = False # default if colors is None
self.colors = colors
self._area = None
self._face_areas = None
self._face_centroids = None
self._vertex_connected_faces = None

@property
def vertices(self):
"""Tuple of all vertices in this geometry."""
Expand Down Expand Up @@ -71,7 +95,7 @@ def area(self):

@property
def face_centroids(self):
"""A tuple of face centroids that parallels the Faces property."""
"""Tuple of face centroids that parallels the Faces property."""
if self._face_centroids is None:
_f_cent = []
for face in self.faces:
Expand All @@ -82,6 +106,18 @@ def face_centroids(self):
self._face_centroids = tuple(_f_cent)
return self._face_centroids

@property
def vertex_connected_faces(self):
"""Tuple with a tuple for each vertex that lists the indexes of connected faces.
"""
if self._vertex_connected_faces is None:
_vert_faces = [[] for i in xrange(len(self._vertices))]
for i, face in enumerate(self._faces):
for j in face:
_vert_faces[j].append(i)
self._vertex_connected_faces = tuple(tuple(vert) for vert in _vert_faces)
return self._vertex_connected_faces

def move(self, moving_vec):
"""Get a mesh that has been moved along a vector.
Expand Down
21 changes: 11 additions & 10 deletions ladybug_geometry/geometry2d/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,6 @@
from .polygon import Polygon2D
from ._2d import Base2DIn2D

try:
from ladybug.color import Color
except ImportError:
Color = None
print('Failed to import ladybug Color.\n'
'Importing mesh colors in from_dict methods will not be availabe.')

try:
from itertools import izip as zip # python 2
except ImportError:
Expand All @@ -37,10 +30,11 @@ class Mesh2D(MeshBase, Base2DIn2D):
centroid
face_areas
face_centroids
vertex_connected_faces
"""
__slots__ = ('_vertices', '_faces', '_colors', '_is_color_by_face',
'_min', '_max', '_center', '_area', '_centroid',
'_face_areas', '_face_centroids')
'_face_areas', '_face_centroids', '_vertex_connected_faces')

def __init__(self, vertices, faces, colors=None):
"""Initilize Mesh2D.
Expand All @@ -64,6 +58,7 @@ def __init__(self, vertices, faces, colors=None):
self._centroid = None
self._face_areas = None
self._face_centroids = None
self._vertex_connected_faces = None

@classmethod
def from_dict(cls, data):
Expand All @@ -72,11 +67,17 @@ def from_dict(cls, data):
Args:
data: {
"vertices": [{"x": 0, "y": 0}, {"x": 10, "y": 0}, {"x": 0, "y": 10}],
"faces": [(0, 1, 2)]
"faces": [(0, 1, 2)],
"colors": [{"r": 255, "g": 0, "b": 0}]
}
"""
colors = None
if Color is not None and 'colors' in data and data['colors'] is not None:
if 'colors' in data and data['colors'] is not None:
try:
from ladybug.color import Color
except ImportError:
raise ImportError('Colors are specified in input Mesh2D dictionary '
'but failed to import ladybug.color')
colors = tuple(Color.from_dict(col) for col in data['colors'])
return cls(tuple(Point2D.from_dict(pt) for pt in data['vertices']),
data['faces'], colors)
Expand Down
88 changes: 77 additions & 11 deletions ladybug_geometry/geometry3d/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,6 @@
from .plane import Plane
from ._2d import Base2DIn3D

try:
from ladybug.color import Color
except ImportError:
Color = None
print('Failed to import ladybug Color.\n'
'Importing mesh colors in from_dict methods will not be availabe.')

try:
from itertools import izip as zip # python 2
except ImportError:
Expand All @@ -38,11 +31,12 @@ class Mesh3D(MeshBase, Base2DIn3D):
face_centroids
face_normals
vertex_normals
vertex_connected_faces
"""
__slots__ = ('_vertices', '_faces', '_colors', '_is_color_by_face',
'_min', '_max', '_center', '_area',
'_face_areas', '_face_centroids', '_face_normals',
'_vertex_normals')
'_vertex_normals', '_vertex_connected_faces')

def __init__(self, vertices, faces, colors=None):
"""Initilize Mesh3D.
Expand All @@ -67,19 +61,27 @@ def __init__(self, vertices, faces, colors=None):
self._face_centroids = None
self._face_normals = None
self._vertex_normals = None
self._vertex_connected_faces = None

@classmethod
def from_dict(cls, data):
"""Create a Mesh3D from a dictionary.
Args:
data: {
"vertices": [{"x": 0, "y": 0}, {"x": 10, "y": 0}, {"x": 0, "y": 10}],
"faces": [(0, 1, 2)]
"vertices": [{"x": 0, "y": 0, "z": 0}, {"x": 10, "y": 0, "z": 0},
{"x": 0, "y": 10, "z": 0}],
"faces": [(0, 1, 2)],
"colors": [{"r": 255, "g": 0, "b": 0}]
}
"""
colors = None
if Color is not None and 'colors' in data and data['colors'] is not None:
if 'colors' in data and data['colors'] is not None:
try:
from ladybug.color import Color
except ImportError:
raise ImportError('Colors are specified in input Mesh2D dictionary '
'but failed to import ladybug.color')
colors = tuple(Color.from_dict(col) for col in data['colors'])
return cls(tuple(Point3D.from_dict(pt) for pt in data['vertices']),
data['faces'], colors)
Expand Down Expand Up @@ -257,6 +259,58 @@ def scale(self, factor, origin=None):
_verts = tuple(pt.scale(factor, origin) for pt in self.vertices)
return self._mesh_scale(_verts, factor)

def offset_mesh(self, distance):
"""Get a Mesh3D that has been offset from this one by a certain difference.
Effectively, this method moves each mesh vertex along the vertex normal
by the offset distance.
Args:
distance: A number for the distance to offset the mesh.
"""
new_verts = tuple(pt.move(norm * distance) for pt, norm in
zip(self.vertices, self.vertex_normals))
return Mesh3D(new_verts, self.faces, self._colors)

def height_field_mesh(self, values, domain):
"""Get a Mesh3D that has faces or vertices offset according to a list of values.
Args:
values: A list of values that has a length matching the number of faces
or vertices in this mesh.
domain: A tuple or list of two numbers for the upper and lower distances
that the mesh vertices should be offset. (ie. (0, 3))
"""
assert isinstance(domain, (tuple, list)), 'Expected tuple for domain. '\
'Got {}.'.format(type(domain))
assert len(domain) == 2, 'Expected domain to be in the format (min, max). ' \
'Got {}.'.format(domain)

if len(values) == len(self.faces):
remap_vals = Mesh3D._remap_values(values, domain[0], domain[-1])
vert_remap_vals = []
for vf in self.vertex_connected_faces:
v = 0
for j in vf:
v += remap_vals[j]
try:
v /= len(vf) # average the vertex value over its connected faces
except ZeroDivisionError:
pass # lone vertex without any faces
vert_remap_vals.append(v)
new_verts = tuple(pt.move(norm * dist) for pt, norm, dist in
zip(self.vertices, self.vertex_normals, vert_remap_vals))
elif len(values) == len(self.vertices):
remap_vals = Mesh3D._remap_values(values, domain[0], domain[-1])
new_verts = tuple(pt.move(norm * dist) for pt, norm, dist in
zip(self.vertices, self.vertex_normals, remap_vals))
else:
raise ValueError(
'Input values for height_field_mesh ({}) does not match the number of'
' mesh faces ({}) nor the number of vertices ({}).'
.format(len(values), len(self.faces), len(self.vertices)))
return Mesh3D(new_verts, self.faces, self._colors)

def to_dict(self):
"""Get Mesh3D as a dictionary."""
colors = None
Expand Down Expand Up @@ -413,6 +467,18 @@ def _get_tri_area(pts):
n1 = v1.cross(v2)
return n1.magnitude / 2

@staticmethod
def _remap_values(values, tmin, tmax):
"""Remap a set of values to offset distances within a domain."""
omin = min(values)
omax = max(values)
odiff = omax - omin
tdiff = tmax - tmin
if odiff == 0:
return [tmin] * len(values)
else:
return [(v - omin) * tdiff / odiff + tmin for v in values]

def __copy__(self):
_new_mesh = Mesh3D(self.vertices, self.faces)
self._transfer_properties(_new_mesh)
Expand Down
6 changes: 3 additions & 3 deletions ladybug_geometry/geometry3d/polyface.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,9 +721,9 @@ def intersect_plane(self, plane):
def get_outward_faces(faces, tolerance=0):
"""Get faces that are all pointing outward from a list of faces together forming a solid.
Note that, if the input faces do not form a closed solid, thre may be some output
faces that are not pointing outward. However, if the gaps in the combined solid
are within the input tolerance, this should not be an issue.
Note that, if the input faces do not form a closed solid, there may be some
output faces that are not pointing outward. However, if the gaps in the
combined solid are within the input tolerance, this should not be an issue.
Also, note that this method runs automatically for any solid polyface
(meaning every solid polyface automatically has outward-facing faces). So there
Expand Down
4 changes: 4 additions & 0 deletions tests/mesh2d_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ def test_mesh2d_init(self):
assert mesh._is_color_by_face is False
assert mesh.colors is None

assert len(mesh.vertex_connected_faces) == 4
for vf in mesh.vertex_connected_faces:
assert len(vf) == 1

def test_mesh2d_to_from_dict(self):
"""Test the to/from dict of Mesh2D objects."""
pts = (Point2D(0, 0), Point2D(0, 2), Point2D(2, 2), Point2D(2, 0))
Expand Down
44 changes: 44 additions & 0 deletions tests/mesh3d_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ def test_mesh3d_init(self):
assert mesh.face_centroids[0] == Point3D(1, 1, 2)
assert mesh._is_color_by_face is False
assert mesh.colors is None
assert len(mesh.vertex_connected_faces) == 4
for vf in mesh.vertex_connected_faces:
assert len(vf) == 1

def test_mesh3d_to_from_dict(self):
"""Test the to/from dict of Mesh3D objects."""
Expand Down Expand Up @@ -357,6 +360,47 @@ def test_reflect(self):
assert test_2[2].y == pytest.approx(-1, rel=1e-3)
assert test_2[2].z == pytest.approx(2, rel=1e-3)

def test_offset_mesh(self):
"""Test the offset_mesh method."""
pts = (Point3D(0, 0, 2), Point3D(0, 2, 2), Point3D(2, 2, 2), Point3D(2, 0, 2))
pts_rev = tuple(reversed(pts))
mesh = Mesh3D(pts, [(0, 1, 2, 3)])
mesh_rev = Mesh3D(pts_rev, [(0, 1, 2, 3)])

new_mesh = mesh.offset_mesh(2)
for v in new_mesh.vertices:
assert v.z == 0

new_mesh_rev = mesh_rev.offset_mesh(2)
for v in new_mesh_rev.vertices:
assert v.z == 4

def test_height_field_mesh(self):
"""Test the height_field_mesh method."""
pts = (Point3D(0, 0, 0), Point3D(2, 0, 0), Point3D(2, 2, 0), Point3D(0, 2, 0))
mesh = Mesh3D(pts, [(0, 1, 2, 3)])
values = [-1, 0, 1, 2]

new_mesh = mesh.height_field_mesh(values, (0, 3))
assert new_mesh[0].z == 0
assert new_mesh[1].z == 1
assert new_mesh[2].z == 2
assert new_mesh[3].z == 3

def test_height_field_mesh_faces(self):
"""Test the height_field_mesh method with values for faces."""
pts = (Point3D(0, 0, 0), Point3D(2, 0, 0), Point3D(2, 2, 0), Point3D(0, 2, 0),
Point3D(4, 0, 0))
mesh = Mesh3D(pts, [(0, 1, 2, 3), (2, 3, 4)])
values = [-1, 1]

new_mesh = mesh.height_field_mesh(values, (1, 2))
assert new_mesh[0].z == 1
assert new_mesh[1].z == 1
assert new_mesh[2].z == 1.5
assert new_mesh[3].z == 1.5
assert new_mesh[4].z == 2


if __name__ == "__main__":
unittest.main()

0 comments on commit 70330da

Please sign in to comment.