From 70330da98b3da66f864af1e299704ec4479611f7 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 3 Jun 2019 12:05:05 -0400 Subject: [PATCH] feat(mesh): Adding methods to create offset and height field meshes ... and responding to comments that @mostapharoudsari left on the original to/from dict PR. --- ladybug_geometry/_mesh.py | 38 ++++++++++- ladybug_geometry/geometry2d/mesh.py | 21 +++--- ladybug_geometry/geometry3d/mesh.py | 88 +++++++++++++++++++++---- ladybug_geometry/geometry3d/polyface.py | 6 +- tests/mesh2d_test.py | 4 ++ tests/mesh3d_test.py | 44 +++++++++++++ 6 files changed, 176 insertions(+), 25 deletions(-) diff --git a/ladybug_geometry/_mesh.py b/ladybug_geometry/_mesh.py index bbb41f96..f7606a14 100644 --- a/ladybug_geometry/_mesh.py +++ b/ladybug_geometry/_mesh.py @@ -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. @@ -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.""" @@ -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: @@ -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. diff --git a/ladybug_geometry/geometry2d/mesh.py b/ladybug_geometry/geometry2d/mesh.py index 1319c090..41816ccf 100644 --- a/ladybug_geometry/geometry2d/mesh.py +++ b/ladybug_geometry/geometry2d/mesh.py @@ -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: @@ -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. @@ -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): @@ -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) diff --git a/ladybug_geometry/geometry3d/mesh.py b/ladybug_geometry/geometry3d/mesh.py index 3c0744dc..dd80d553 100644 --- a/ladybug_geometry/geometry3d/mesh.py +++ b/ladybug_geometry/geometry3d/mesh.py @@ -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: @@ -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. @@ -67,6 +61,7 @@ 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): @@ -74,12 +69,19 @@ def from_dict(cls, data): 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) @@ -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 @@ -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) diff --git a/ladybug_geometry/geometry3d/polyface.py b/ladybug_geometry/geometry3d/polyface.py index f3bfd7d6..bef7789b 100644 --- a/ladybug_geometry/geometry3d/polyface.py +++ b/ladybug_geometry/geometry3d/polyface.py @@ -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 diff --git a/tests/mesh2d_test.py b/tests/mesh2d_test.py index 13402b48..a4a37639 100644 --- a/tests/mesh2d_test.py +++ b/tests/mesh2d_test.py @@ -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)) diff --git a/tests/mesh3d_test.py b/tests/mesh3d_test.py index 0cb21480..eda5be08 100644 --- a/tests/mesh3d_test.py +++ b/tests/mesh3d_test.py @@ -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.""" @@ -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()