Source code for ladybug.viewsphere

# coding=utf-8
"""Class to subdivide the sphere and hemisphere for view-based and radiation studies."""
from __future__ import division

import math

from ladybug_geometry.geometry2d.pointvector import Vector2D
from ladybug_geometry.geometry3d.pointvector import Point3D, Vector3D
from ladybug_geometry.geometry3d.mesh import Mesh3D


[docs]class ViewSphere(object): """Class for subdividing the sphere and hemisphere for view-based studies. Note: [1] Tregenza, Peter. (1987). Subdivision of the sky hemisphere for luminance measurements. Lighting Research & Technology - LIGHTING RES TECHNOL. 19. 13-14. 10.1177/096032718701900103. Properties: * tregenza_dome_vectors * tregenza_sphere_vectors * tregenza_dome_mesh * tregenza_dome_mesh_high_res * tregenza_sphere_mesh * tregenza_solid_angles * reinhart_dome_vectors * reinhart_sphere_vectors * reinhart_dome_mesh * reinhart_sphere_mesh * reinhart_solid_angles """ # number of patches in each row of the most-used sky domes TREGENZA_PATCHES_PER_ROW = (30, 30, 24, 24, 18, 12, 6) REINHART_PATCHES_PER_ROW = (60, 60, 60, 60, 48, 48, 48, 48, 36, 36, 24, 24, 12, 12) # number of steradians for the patches of each row of the most-used sky domes TREGENZA_COEFFICIENTS = \ (0.0435449227, 0.0416418006, 0.0473984151, 0.0406730411, 0.0428934136, 0.0445221864, 0.0455168385, 0.0344199465) REINHART_COEFFICIENTS = \ (0.0113221971, 0.0111894547, 0.0109255262, 0.0105335058, 0.0125224872, 0.0117312774, 0.0108025291, 0.00974713106, 0.011436609, 0.00974295956, 0.0119026242, 0.00905126163, 0.0121875626, 0.00612971396, 0.00921483254) __slots__ = ('_tregenza_dome_vectors', '_tregenza_sphere_vectors', '_tregenza_dome_mesh', '_tregenza_dome_mesh_high_res', '_tregenza_sphere_mesh', '_tregenza_solid_angles', '_reinhart_dome_vectors', '_reinhart_sphere_vectors', '_reinhart_dome_mesh', '_reinhart_sphere_mesh', '_reinhart_solid_angles') def __init__(self): """Create the ViewSphere.""" # everything starts with None and properties are generated as requested self._tregenza_dome_vectors = None self._tregenza_sphere_vectors = None self._tregenza_dome_mesh = None self._tregenza_dome_mesh_high_res = None self._tregenza_sphere_mesh = None self._tregenza_solid_angles = None self._reinhart_dome_vectors = None self._reinhart_sphere_vectors = None self._reinhart_dome_mesh = None self._reinhart_sphere_mesh = None self._reinhart_solid_angles = None @property def tregenza_dome_vectors(self): """An array of 145 vectors representing the Tregenza sky dome.""" if self._tregenza_dome_vectors is None: self._tregenza_dome_mesh, self._tregenza_dome_vectors = self.dome_patches() return self._tregenza_dome_vectors @property def tregenza_sphere_vectors(self): """An array of 290 vectors representing a sphere of Tregenza vectors.""" if self._tregenza_sphere_vectors is None: self._tregenza_sphere_mesh, self._tregenza_sphere_vectors = \ self.sphere_patches() return self._tregenza_sphere_vectors @property def tregenza_dome_mesh(self): """An Mesh3D representing the Tregenza sky dome. There is one quad face per patch except for the last circular patch, which is represented by 6 triangles. """ if self._tregenza_dome_mesh is None: self._tregenza_dome_mesh, self._tregenza_dome_vectors = self.dome_patches() return self._tregenza_dome_mesh @property def tregenza_dome_mesh_high_res(self): """An high-resolution Mesh3D representing the Tregenza sky dome. Each patch is represented by a 3x3 set of quad faces except for the last circular patch, which is represented by 18 triangles. """ if self._tregenza_dome_mesh_high_res is None: self._tregenza_dome_mesh_high_res, _ = self.dome_patches(3, True) return self._tregenza_dome_mesh_high_res @property def tregenza_sphere_mesh(self): """An Mesh3D representing a Tregenza sphere. There is one quad face per patch except for the two circular patches, which are each represented by 6 triangles. """ if self._tregenza_sphere_mesh is None: self._tregenza_sphere_mesh, self._tregenza_sphere_vectors = \ self.sphere_patches() return self._tregenza_sphere_mesh @property def tregenza_solid_angles(self): """Get a list of solid angles that align with the tregenza_dome_vectors.""" if self._reinhart_solid_angles is None: angles = view_sphere.TREGENZA_COEFFICIENTS patch_rows = view_sphere.TREGENZA_PATCHES_PER_ROW + (1,) patch_angles = [] for ang, p_count in zip(angles, patch_rows): patch_angles.extend([ang] * p_count) self._reinhart_solid_angles = tuple(patch_angles) return self._reinhart_solid_angles @property def reinhart_dome_vectors(self): """An array of 577 vectors representing the Reinhart sky dome.""" if self._reinhart_dome_vectors is None: self._reinhart_dome_mesh, self._reinhart_dome_vectors = self.dome_patches(2) return self._reinhart_dome_vectors @property def reinhart_sphere_vectors(self): """An array of 1154 vectors representing a sphere of Reinhart vectors.""" if self._reinhart_sphere_vectors is None: self._reinhart_sphere_mesh, self._reinhart_sphere_vectors = \ self.sphere_patches(2) return self._reinhart_sphere_vectors @property def reinhart_dome_mesh(self): """An Mesh3D representing the Reinhart sky dome. There is one quad face per patch except for the last circular patch, which is represented by 12 triangles. """ if self._reinhart_dome_mesh is None: self._reinhart_dome_mesh, self._reinhart_dome_vectors = self.dome_patches(2) return self._reinhart_dome_mesh @property def reinhart_sphere_mesh(self): """An Mesh3D representing a Reinhart sphere. There is one quad face per patch except for the two circular patches, which are each represented by 12 triangles. """ if self._reinhart_sphere_mesh is None: self._reinhart_sphere_mesh, self._reinhart_sphere_vectors = \ self.sphere_patches(2) return self._reinhart_sphere_mesh @property def reinhart_solid_angles(self): """Get a list of solid angles that align with the reinhart_dome_vectors.""" if self._reinhart_solid_angles is None: angles = view_sphere.REINHART_COEFFICIENTS patch_rows = view_sphere.REINHART_PATCHES_PER_ROW + (1,) patch_angles = [] for ang, p_count in zip(angles, patch_rows): patch_angles.extend([ang] * p_count) self._reinhart_solid_angles = tuple(patch_angles) return self._reinhart_solid_angles
[docs] def horizontal_radial_vectors(self, vector_count): """Get perfectly horizontal Vector3Ds radiating outward in a circle. Args: vector_count: An integer for the number of vectors to generate in the horizontal plane. This can align with any of the dome or sphere patches by setting this to 30 * division_count. Returns: A list of ladybug_geometry horizontal Vector3D radiating outward in a circle. All vectors are unit vectors. """ base_vec = Vector3D(0, 1, 0) horiz_angle = -2 * math.pi / vector_count return tuple(base_vec.rotate_xy(horiz_angle * i) for i in range(vector_count))
[docs] def horizontal_radial_patches(self, offset_angle=30, division_count=1, subdivide_in_place=False): """Get Vector3Ds within a certain angle offset from the horizontal plane. Args: offset_angle: A number between 0 and 90 for the angle offset from the horizontal plane at which vectors will be included. Vectors both above and below this angle will be included (Default: 30 for the rough vertical limit of human peripheral vision). division_count: A positive integer for the number of times that the original Tregenza patches are subdivided. 1 indicates that the original Tregenza patches will be used, 2 indicates the Reinhart patches will be used, and so on. (Default: 1). subdivide_in_place: A boolean to note whether patches should be subdivided according to the extension of Tregenza's original logic through the Reinhart method (False) or they should be simply divided into 4 in place (True). Returns: A tuple with two elements - patch_mesh: A ladybug_geometry Mesh3D that represents the patches at the input division_count. There is one quad face per patch. - patch_vectors: A list of ladybug_geometry Vector3D with one vector per patch. These will align with the faces of the patch_mesh. All vectors are unit vectors. """ # figure out how many rows and patches should be in the output patch_row_count = self._patch_row_count_array(division_count) patch_count = self._patch_count_in_radial_offset( offset_angle, division_count, patch_row_count, subdivide_in_place) # get the dome and vectors and remove faces up tot the patch count m_all, v_all = self.dome_patches(division_count, subdivide_in_place) pattern = [True] * patch_count + \ [False] * (sum(patch_row_count) - patch_count + 6 * division_count) m_top, _ = m_all.remove_faces(pattern) v_top = tuple(vec for vec, val in zip(v_all, pattern) if val) # reverse the vectors and negate all the z values of the sky patch mesh return self._generate_bottom_from_top(m_top, v_top)
[docs] def horizontal_radial_patch_weights(self, offset_angle=30, division_count=1): """Get a list of numbers corresponding to the area weight of each radial patch. Args: offset_angle: A number between 0 and 90 for the angle offset from the horizontal plane at which vectors will be included. Vectors both above and below this angle will be included (Default: 30). division_count: A positive integer for the number of times that the original Tregenza patches are subdivided. (Default: 1). Returns: A list of numbers with a value for each patch that corresponds to the area of that patch. The average value of all the patches is equal to 1. """ # get the areas of the patches and the number of patches to include in the offset patch_areas, patch_row_count = self._dome_patch_areas(division_count) patch_count = self._patch_count_in_radial_offset( offset_angle, division_count, patch_row_count) # normalize the patch areas so that they average to 1 relevant_patches = patch_areas[:patch_count] avg_patch_area = sum(relevant_patches) / len(relevant_patches) return [p_area / avg_patch_area for p_area in relevant_patches] * 2
[docs] def dome_patches(self, division_count=1, subdivide_in_place=False): """Get Vector3Ds and a corresponding Mesh3D for a dome. Args: division_count: A positive integer for the number of times that the original Tregenza patches are subdivided. 1 indicates that the original Tregenza patches will be used, 2 indicates the Reinhart patches will be used, and so on. (Default: 1). subdivide_in_place: A boolean to note whether patches should be subdivided according to the extension of Tregenza's original logic through the Reinhart method (False) or they should be simply divided into 4 in place (True). The latter is useful for making higher resolution Mesh visualizations of an inherently low-resolution dome. Returns: A tuple with two elements - patch_mesh: A ladybug_geometry Mesh3D that represents the dome at the input division_count. There is one quad face per patch except for the last circular patch, which is represented by a number of triangles equal to division_count * 6. - patch_vectors: A list of ladybug_geometry Vector3D with one vector per patch. These will align with the faces of the patch_mesh up until the last circular patch, which will have a single vector for the several triangular faces. All vectors are unit vectors. """ # compute constants to be used in the generation of patch points patch_row_count = self._patch_row_count_array(division_count) base_vec = Vector3D(0, 1, 0) rotate_axis = Vector3D(1, 0, 0) vertical_angle = math.pi / (2 * len(patch_row_count) + division_count) if \ subdivide_in_place else math.pi / (2 * len(patch_row_count) + 1) # loop through the patch values and generate points for each vertex vertices, faces = [], [] pt_i = -2 # track the number of vertices in the mesh for row_i, row_count in enumerate(patch_row_count): pt_i += 2 # advance the number of vertices by two horiz_angle = -2 * math.pi / row_count # horizontal angle of each patch vec01 = base_vec.rotate(rotate_axis, vertical_angle * row_i) vec02 = vec01.rotate(rotate_axis, vertical_angle) correction_angle = -horiz_angle / 2 if subdivide_in_place: correction_angle * division_count vec1 = vec01.rotate_xy(correction_angle) vec2 = vec02.rotate_xy(correction_angle) vertices.extend((Point3D(v.x, v.y, v.z) for v in (vec1, vec2))) for _ in range(row_count): # generate the row of patches vec3 = vec1.rotate_xy(horiz_angle) vec4 = vec2.rotate_xy(horiz_angle) vertices.extend((Point3D(v.x, v.y, v.z) for v in (vec3, vec4))) faces.append((pt_i, pt_i + 1, pt_i + 3, pt_i + 2)) pt_i += 2 # advance the number of vertices by two vec1, vec2 = vec3, vec4 # reset vec1 and vec2 for the next patch # add triangular faces to represent the last circular patch end_vert_i = len(vertices) start_vert_i = len(vertices) - patch_row_count[-1] * 2 - 1 vertices.append(Point3D(0, 0, 1)) for tr_i in range(0, patch_row_count[-1] * 2, 2): faces.append((start_vert_i + tr_i, end_vert_i, start_vert_i + tr_i + 2)) # create the Mesh3D object and derive the patch vectors from the mesh patch_mesh = Mesh3D(vertices, faces) patch_vectors = patch_mesh.face_normals[:-patch_row_count[-1]] + \ (Vector3D(0, 0, 1),) return patch_mesh, patch_vectors
[docs] def dome_patch_weights(self, division_count=1): """Get a list of numbers corresponding to the area weight of each dome patch. Args: division_count: A positive integer for the number of times that the original Tregenza patches are subdivided. (Default: 1). Returns: A list of numbers with a value for each patch that corresponds to the area of that patch. The average value of all the patches is equal to 1. """ # get the areas of the patches patch_areas, _ = self._dome_patch_areas(division_count) # normalize the patch areas so that they average to 1 avg_patch_area = 2 * math.pi / len(patch_areas) return [p_area / avg_patch_area for p_area in patch_areas]
[docs] def sphere_patches(self, division_count=1, subdivide_in_place=False): """Get Vector3Ds and a corresponding Mesh3D for a sphere. Args: division_count: A positive integer for the number of times that the original Tregenza patches are subdivided. 1 indicates that the original Tregenza patches will be used, 2 indicates the Reinhart patches will be used, and so on. (Default: 1). subdivide_in_place: A boolean to note whether patches should be subdivided according to the extension of Tregenza's original logic through the Reinhart method (False) or they should be simply divided into 4 in place (True). The latter is useful for making higher resolution Mesh visualizations of an inherently low-resolution dome. Returns: A tuple with two elements - patch_mesh: A ladybug_geometry Mesh3D that represents the sphere at the input division_count. There is one quad face per patch except for the last circular patch of each hemisphere, which is represented by a number of triangles equal to division_count * 6. - patch_vectors: A list of ladybug_geometry Vector3D with one vector per patch. These will align with the faces of the patch_mesh except for the two circular patches, which will have a single vector for the several triangular faces. All vectors are unit vectors. """ # generate patches for the hemisphere m_top, v_top = self.dome_patches(division_count, subdivide_in_place) # reverse the vectors and negate all the z values of the sky patch mesh return self._generate_bottom_from_top(m_top, v_top)
[docs] def sphere_patch_weights(self, division_count=1): """Get a list of numbers corresponding to the area weight of each sphere patch. Args: division_count: A positive integer for the number of times that the original Tregenza patches are subdivided. (Default: 1). Returns: A list of numbers with a value for each patch that corresponds to the area of that patch. The average value of all the patches is equal to 1. """ # get the areas of the patches patch_areas, _ = self._dome_patch_areas(division_count) # normalize the patch areas so that they average to 1 avg_patch_area = 2 * math.pi / len(patch_areas) return [p_area / avg_patch_area for p_area in patch_areas] * 2
[docs] def dome_radial_patches(self, azimuth_count=72, altitude_count=18): """Get Vector3Ds and a corresponding Mesh3D for a a radial dome. Args: azimuth_count: A positive integer for the number of times that the horizontal circle will be subdivided into azimuth patches. (Default: 72). altitude_count: A positive integer for the number of times that the dome quarter-circle will be subdivided into altitude patches. (Default: 18). Returns: A tuple with two elements - patch_mesh: A ladybug_geometry Mesh3D that represents the patches at the input azimuth_count and altitude_count. - patch_vectors: A list of ladybug_geometry Vector3D with one vector per mesh face. These will align with the faces of the patch_mesh. All vectors are unit vectors. """ # set up starting vectors and points base_vec, rotate_axis = Vector3D(0, 1, 0), Vector3D(1, 0, 0) horiz_angle = -2 * math.pi / azimuth_count vertical_angle = math.pi / (2 * altitude_count) # loop through the patch values and generate points for each vertex vertices, faces = [], [] pt_i = -2 # track the number of vertices in the mesh for row_i in range(altitude_count - 1): pt_i += 2 # advance the number of vertices by two vec1 = base_vec.rotate(rotate_axis, vertical_angle * row_i) vec2 = vec1.rotate(rotate_axis, vertical_angle) vertices.extend((Point3D(v.x, v.y, v.z) for v in (vec1, vec2))) for _ in range(azimuth_count): # generate the row of patches vec3 = vec1.rotate_xy(horiz_angle) vec4 = vec2.rotate_xy(horiz_angle) vertices.extend((Point3D(v.x, v.y, v.z) for v in (vec3, vec4))) faces.append((pt_i, pt_i + 1, pt_i + 3, pt_i + 2)) pt_i += 2 # advance the number of vertices by two vec1, vec2 = vec3, vec4 # reset vec1 and vec2 for the next patch # add triangular faces to represent the last circular patch end_vert_i = len(vertices) start_vert_i = len(vertices) - azimuth_count * 2 - 1 vertices.append(Point3D(0, 0, 1)) for tr_i in range(0, azimuth_count * 2, 2): faces.append((start_vert_i + tr_i, end_vert_i, start_vert_i + tr_i + 2)) # create the Mesh3D object and derive the patch vectors from the mesh patch_mesh = Mesh3D(vertices, faces) patch_vectors = patch_mesh.face_normals return patch_mesh, patch_vectors
[docs] def dome_radial_patch_weights(self, azimuth_count=72, altitude_count=18): """Get a list of numbers corresponding to the area weight of each dome patch. Args: azimuth_count: A positive integer for the number of times that the horizontal circle will be subdivided into azimuth patches. (Default: 72). altitude_count: A positive integer for the number of times that the dome quarter-circle will be subdivided into altitude patches. (Default: 18). Returns: A list of numbers with a value for each patch that corresponds to the area of that patch. The average value of all the patches is equal to 1. """ # get the areas of the patches patch_areas = self._dome_radial_patch_areas(azimuth_count, altitude_count) # normalize the patch areas so that they average to 1 total_patch_area = 2 * math.pi return [p_area / total_patch_area for p_area in patch_areas]
[docs] def horizontal_circle_view_mesh( self, center_point=Point3D(0, 0, 0), radius=1, azimuth_count=72): """Get a mesh of a horizontal circle with vertices coordinated with view_vectors. Args: center_point: A Point3D for the center of the mesh. (Default: (0, 0, 0)). radius: A number for the radius of the circle. (Default: 1). azimuth_count: A positive integer greater than or equal to 3 for the number of times that the horizontal circle will be subdivided into vertices. (Default: 72). Returns: A tuple with two elements - circle_mesh: A ladybug_geometry circular Mesh3D that represents the horizontal view at the input azimuth_count. - view_vecs: A tuple of ladybug_geometry Vector3D with one vector per mesh vertex. The first vertex of the circle_mesh is the center and each one after that is coordinated with the vector here. """ # generate a list of vectors over the circle view_vecs = self.horizontal_radial_vectors(azimuth_count) # use the direction vectors to create a mesh of the sky dome vertices, faces = [center_point], [] for i, vec in enumerate(view_vecs): vertices.append(center_point.move(vec * radius)) faces.append((0, i + 1, i + 2)) faces.pop(-1) faces.append((0, azimuth_count, 1)) circle_mesh = Mesh3D(vertices, faces) return circle_mesh, view_vecs
[docs] def horizontal_radial_view_mesh( self, center_point=Point3D(0, 0, 0), radius=1, offset_angle=30, azimuth_count=72, altitude_count=6): """Get a mesh of a radial circle with vertices coordinated with view_vectors. Args: center_point: A Point3D for the center of the mesh. (Default: (0, 0, 0)). radius: A number for the radius of the circle. (Default: 1). offset_angle: A number between 0 and 90 for the angle offset from the horizontal plane at which vectors will be included. Vectors both above and below this angle will be included (Default: 30). azimuth_count: A positive integer greater than or equal to 3 for the number of times that the horizontal circle will be subdivided into vertices. (Default: 72). altitude_count: An integer greater than or equal to 1, which notes the number of vertical orientations at which the altitude will be evaluated. (Default: 18). Returns: A tuple with two elements - radial_mesh: A ladybug_geometry Mesh3D that represents the horizontal radial view at the input azimuth_count and altitude_count. - view_vecs: A list of ladybug_geometry Vector3D with one vector per mesh vertex. The first vertex of the radial_mesh is the center and each one after that is coordinated with the vector here. """ # compute the global parameters for generating the mesh horiz_angle = -2 * math.pi / azimuth_count vert_angle = (math.radians(offset_angle)) / altitude_count base_vec, x_axis = Vector3D(0, 1, 0), Vector3D(1, 0, 0) # generate a list of vectors over the circle view_vecs = list(self.horizontal_radial_vectors(azimuth_count)) vertices, faces = [center_point], [] for vec in view_vecs: vertices.append(center_point.move(vec * radius)) # generate a list of vectors over the horizontal radial domain up_i1, up_i2, up_i3, up_i4 = 2, 1, azimuth_count + 1, azimuth_count + 3 dn_i1, dn_i2, dn_i3, dn_i4 = 1, 2, azimuth_count + 4, azimuth_count + 2 for v in range(1, altitude_count + 1): up_vec = base_vec.rotate(x_axis, vert_angle * v) dn_vec = base_vec.rotate(x_axis, vert_angle * -v) view_vecs.append(up_vec) view_vecs.append(dn_vec) vertices.append(center_point.move(up_vec * radius)) vertices.append(center_point.move(dn_vec * radius)) vi = 1 if v == 1 else 2 for h in range(1, azimuth_count): vv_up = up_vec.rotate_xy(horiz_angle * h) vv_dn = dn_vec.rotate_xy(horiz_angle * h) view_vecs.append(vv_up) view_vecs.append(vv_dn) vertices.append(center_point.move(vv_up * radius)) vertices.append(center_point.move(vv_dn * radius)) faces.append((up_i1, up_i2, up_i3, up_i4)) faces.append((dn_i1, dn_i2, dn_i3, dn_i4)) up_i1, up_i2, up_i3, up_i4 = up_i1 + vi, up_i2 + vi, up_i3 + 2, up_i4 + 2 dn_i1, dn_i2, dn_i3, dn_i4 = dn_i1 + vi, dn_i2 + vi, dn_i3 + 2, dn_i4 + 2 sub_i = azimuth_count if v == 1 else 2 * azimuth_count faces.append((up_i1 - sub_i, up_i2, up_i3, up_i4 - (2 * azimuth_count))) faces.append((dn_i1, dn_i2 - sub_i, dn_i3 - (2 * azimuth_count), dn_i4)) ri = 3 if v == 1 else 2 up_i1, up_i2 = up_i1 + 2, up_i2 + vi up_i3, up_i4 = up_i3 + 2, up_i4 + 2 dn_i1, dn_i2 = dn_i1 + 2, dn_i2 + ri dn_i3, dn_i4 = dn_i3 + 2, dn_i4 + 2 # add a series of triangular faces to fill in the top and bottom of the mesh az_2 = (2 * azimuth_count) up_i3, up_i4 = up_i3 - az_2, up_i4 - az_2 dn_i3, dn_i4 = dn_i3 - az_2, dn_i4 - az_2 for h in range(azimuth_count - 1): faces.append((0, up_i4, up_i3)) faces.append((0, dn_i4, dn_i3)) up_i3, up_i4 = up_i3 + 2, up_i4 + 2 dn_i3, dn_i4 = dn_i3 + 2, dn_i4 + 2 faces.append((0, up_i4 - az_2, up_i3)) faces.append((0, dn_i3 - 2, dn_i4 - az_2 + 2)) radial_mesh = Mesh3D(vertices, faces) return radial_mesh, view_vecs
[docs] def dome_view_mesh( self, center_point=Point3D(0, 0, 0), radius=1, azimuth_count=72, altitude_count=18): """Get a mesh of a horizontal circle with vertices coordinated with view_vectors. Args: center_point: A Point3D for the center of the mesh. (Default: (0, 0, 0)). radius: A number for the radius of the circle. (Default: 1). azimuth_count: An integer greater than or equal to 3, which notes the number of horizontal orientations to be evaluated on the dome. (Default: 72). altitude_count: An integer greater than or equal to 3, which notes the number of vertical orientations to be evaluated on the dome. (Default: 18). Returns: A tuple with two elements - dome_mesh: A ladybug_geometry Mesh3D that represents the hemispherical view dome at the input azimuth_count and altitude_count. - view_vecs: A list of ladybug_geometry Vector3D with one vector per mesh vertex. """ # generate a list of vectors over the dome horiz_angle = -2 * math.pi / azimuth_count vert_angle = (math.pi / 2) / altitude_count view_vecs = [] for v in range(altitude_count): x_axis = Vector3D(1, 0, 0) base_vec = Vector3D(0, 1, 0) n_vec = base_vec.rotate(x_axis, vert_angle * v) for h in range(azimuth_count): view_vecs.append(n_vec.rotate_xy(horiz_angle * h)) view_vecs.append(Vector3D(0, 0, 1)) # use the direction vectors to create a mesh of the sky dome vertices = [] for vec in view_vecs: vertices.append(center_point.move(vec * radius)) faces, pt_i, az_ct = [], 0, azimuth_count for _ in range(altitude_count - 1): for _ in range(az_ct - 1): faces.append((pt_i, pt_i + 1, pt_i + az_ct + 1, pt_i + az_ct)) pt_i += 1 # advance the number of vertices faces.append((pt_i, pt_i - az_ct + 1, pt_i + 1, pt_i + az_ct)) pt_i += 1 # advance the number of vertices # add triangular faces to represent the last circular patch end_vert_i = len(vertices) - 1 start_vert_i = len(vertices) - azimuth_count - 1 for tr_i in range(0, azimuth_count - 1): faces.append((start_vert_i + tr_i, end_vert_i, start_vert_i + tr_i + 1)) faces.append((end_vert_i - 1, end_vert_i, start_vert_i)) dome_mesh = Mesh3D(vertices, faces) return dome_mesh, view_vecs
[docs] def sphere_view_mesh( self, center_point=Point3D(0, 0, 0), radius=1, azimuth_count=72, altitude_count=18): """Get a mesh of a sphere with vertices coordinated with view_vectors. Args: center_point: A Point3D for the center of the mesh. (Default: (0, 0, 0)). radius: A number for the radius of the sphere. (Default: 1). azimuth_count: An integer greater than or equal to 3, which notes the number of horizontal orientations to be evaluated on the sphere. (Default: 72). altitude_count: An integer greater than or equal to 3, which notes the number of vertical orientations to be evaluated on the sphere. (Default: 18). Returns: A tuple with two elements - sphere_mesh: A ladybug_geometry Mesh3D that represents the view sphere at the input azimuth_count and altitude_count. - view_vecs: A list of ladybug_geometry Vector3D with one vector per mesh vertex. """ # compute the global parameters for generating the mesh horiz_angle = -2 * math.pi / azimuth_count vert_angle = (math.pi / 2) / altitude_count base_vec, x_axis = Vector3D(0, 1, 0), Vector3D(1, 0, 0) # generate a list of vectors over the circle view_vecs = list(self.horizontal_radial_vectors(azimuth_count)) vertices, faces = [], [] for vec in view_vecs: vertices.append(center_point.move(vec * radius)) # generate a list of vectors over the horizontal radial domain up_i1, up_i2, up_i3, up_i4 = 1, 0, azimuth_count, azimuth_count + 2 dn_i1, dn_i2, dn_i3, dn_i4 = 0, 1, azimuth_count + 3, azimuth_count + 1 for v in range(1, altitude_count): up_vec = base_vec.rotate(x_axis, vert_angle * v) dn_vec = base_vec.rotate(x_axis, vert_angle * -v) view_vecs.append(up_vec) view_vecs.append(dn_vec) vertices.append(center_point.move(up_vec * radius)) vertices.append(center_point.move(dn_vec * radius)) vi = 1 if v == 1 else 2 for h in range(1, azimuth_count): vv_up = up_vec.rotate_xy(horiz_angle * h) vv_dn = dn_vec.rotate_xy(horiz_angle * h) view_vecs.append(vv_up) view_vecs.append(vv_dn) vertices.append(center_point.move(vv_up * radius)) vertices.append(center_point.move(vv_dn * radius)) faces.append((up_i1, up_i2, up_i3, up_i4)) faces.append((dn_i1, dn_i2, dn_i3, dn_i4)) up_i1, up_i2, up_i3, up_i4 = up_i1 + vi, up_i2 + vi, up_i3 + 2, up_i4 + 2 dn_i1, dn_i2, dn_i3, dn_i4 = dn_i1 + vi, dn_i2 + vi, dn_i3 + 2, dn_i4 + 2 sub_i = azimuth_count if v == 1 else 2 * azimuth_count faces.append((up_i1 - sub_i, up_i2, up_i3, up_i4 - (2 * azimuth_count))) faces.append((dn_i1, dn_i2 - sub_i, dn_i3 - (2 * azimuth_count), dn_i4)) ri = 3 if v == 1 else 2 up_i1, up_i2 = up_i1 + 2, up_i2 + vi up_i3, up_i4 = up_i3 + 2, up_i4 + 2 dn_i1, dn_i2 = dn_i1 + 2, dn_i2 + ri dn_i3, dn_i4 = dn_i3 + 2, dn_i4 + 2 # add a series of triangular faces to fill in the top and bottom of the mesh top_vec, bot_vec = Vector3D(0, 0, 1), Vector3D(0, 0, -1) top_i = len(vertices) bot_i = top_i + 1 view_vecs.append(top_vec) view_vecs.append(bot_vec) vertices.append(center_point.move(top_vec * radius)) vertices.append(center_point.move(bot_vec * radius)) az_2 = (2 * azimuth_count) up_i3, up_i4 = up_i3 - az_2, up_i4 - az_2 dn_i3, dn_i4 = dn_i3 - az_2, dn_i4 - az_2 for h in range(azimuth_count - 1): faces.append((top_i, up_i4, up_i3)) faces.append((bot_i, dn_i4, dn_i3)) up_i3, up_i4 = up_i3 + 2, up_i4 + 2 dn_i3, dn_i4 = dn_i3 + 2, dn_i4 + 2 faces.append((top_i, up_i4 - az_2, up_i3)) faces.append((bot_i, dn_i3 - 2, dn_i4 - az_2 + 2)) radial_mesh = Mesh3D(vertices, faces) return radial_mesh, view_vecs
[docs] @staticmethod def orientation_pattern(plane_normal, view_vectors): """Get booleans for whether view vectors are blocked by a plane. Args: plane_normal: A Vector3D for the normal of the plane. view_vectors: A list of view vectors which will be evaluated to determine if they are blocked by the plane or not. Returns: A tuple with two values. - mask_pattern -- A list of booleans for whether each of the view vectors are blocked by the plane (True) or not (False). - angles -- A list of angles in radians for the angle between the plane normal and each view vector. """ mask_pattern, angles, max_angle = [], [], math.pi / 2 for vec in view_vectors: ang = vec.angle(plane_normal) angles.append(ang) mask_pattern.append(ang > max_angle) return mask_pattern, angles
[docs] @staticmethod def overhang_pattern(plane_normal, overhang_angle, view_vectors): """Get booleans for whether a view vectors are blocked by a overhang. Args: plane_normal: A Vector3D for the normal of the plane. overhang_angle: A number between 0 and 90 for the projection angle of an overhang in degrees. view_vectors: A list of view vectors which will be evaluated to determine if they are blocked by the plane or not. Returns: A list of booleans for whether each of the view vectors are blocked by the overhang (True) or not (False). """ overhang_norm = plane_normal.reverse() rotation_axis = overhang_norm.rotate_xy(-math.pi / 2) rotation_axis = Vector3D(rotation_axis.x, rotation_axis.y, 0) overhang_norm = overhang_norm.rotate(rotation_axis, math.radians(overhang_angle)) max_angle = math.pi / 2 return [vec.angle(overhang_norm) < max_angle for vec in view_vectors]
[docs] @staticmethod def fin_pattern(plane_normal, left_fin_angle, right_fin_angle, view_vectors): """Get booleans for whether a view vectors are blocked by left and right fins. Args: plane_normal: A Vector3D for the normal of the plane. left_fin_angle: A number between 0 and 90 for the projection angle of a fin on the left side in degrees. right_fin_angle: A number between 0 and 90 for the projection angle of a fin on the right side in degrees. view_vectors: A list of view vectors which will be evaluated to determine if they are blocked by the plane or not. Returns: A list of booleans for whether each of the view vectors are blocked by the fins (True) or not (False). """ # get the min and max angles for the area not blocked by fins y_axis, norm_2d = Vector2D(0, 1), Vector2D(plane_normal.x, plane_normal.y) srf_angle = math.degrees(norm_2d.angle_clockwise(y_axis)) angle_min = srf_angle - 90 + right_fin_angle \ if right_fin_angle else srf_angle - 90 angle_max = srf_angle + 90 - left_fin_angle \ if left_fin_angle else srf_angle + 90 if angle_max > 360: angle_max, angle_min = angle_max - 360, angle_min - 360 if angle_max < 0: angle_max, angle_min = angle_max + 360, angle_min + 360 # evaluate the view_vectors in relation to the min and max angle mask_pattern = [] for vec in view_vectors: ang = math.degrees(Vector2D(vec.x, vec.y).angle_clockwise(y_axis)) is_visible = (ang < angle_max and ang > angle_min) if angle_min > 0 else \ (ang < angle_max or ang > angle_min + 360) mask_pattern.append(not is_visible) return mask_pattern
@staticmethod def _dome_radial_patch_areas(azimuth_count=72, altitude_count=18): """Get the area of each patch in a radial dome.""" # get the areas of each spherical cap moving up the unit dome vert_angle = math.pi / (2 * altitude_count) cap_areas = [2 * math.pi] current_angle = vert_angle for i in range(altitude_count): cap_areas.append(2 * math.pi * (1 - math.sin(current_angle))) current_angle += vert_angle # get the area of each row and subdivide it by the patch count of the row row_areas = [cap_areas[i] - cap_areas[i + 1] for i in range(len(cap_areas) - 1)] patch_areas = [] for row_area in row_areas: patch_areas.extend([row_area / azimuth_count] * azimuth_count) return patch_areas @staticmethod def _dome_patch_areas(division_count): """Get the area of each patch in a dome from a division_count.""" # get the areas of each spherical cap moving up the unit dome patch_row_count = ViewSphere._patch_row_count_array(division_count) vert_angle = math.pi / (2 * len(patch_row_count) + division_count) cap_areas = [2 * math.pi] current_angle = vert_angle for i in range(len(patch_row_count)): cap_areas.append(2 * math.pi * (1 - math.sin(current_angle))) current_angle += vert_angle # get the area of each row and subdivide it by the patch count of the row row_areas = [cap_areas[i] - cap_areas[i + 1] for i in range(len(cap_areas) - 1)] patch_areas = [] for row_count, row_area in zip(patch_row_count, row_areas): patch_areas.extend([row_area / row_count] * row_count) patch_areas.append(cap_areas[-1]) return patch_areas, patch_row_count @staticmethod def _patch_count_in_radial_offset(offset_angle, division_count, patch_row_count, subdivide_in_place=False): """Get the number of patches within a radial offset from the horizontal plane.""" rad_angle = math.radians(offset_angle) patch_rows = len(patch_row_count) vert_angle = math.pi / (2 * patch_rows + division_count) if subdivide_in_place \ else math.pi / (2 * patch_rows + 1) row_count = int(round(rad_angle / vert_angle)) return sum(patch_row_count[:row_count]) @staticmethod def _patch_row_count_array(division_count): """Get an array of the number of patches in each dome row from division_count.""" patch_row_count = ViewSphere.TREGENZA_PATCHES_PER_ROW if division_count != 1: patch_row_count = [init_ct * division_count for init_ct in patch_row_count for i in range(division_count)] return patch_row_count @staticmethod def _generate_bottom_from_top(m_top, v_top): """Get a joined mesh and vectors for top and bottom from only top vectors.""" # reverse the vectors and negate all the z values of the sky patch mesh verts = tuple(Point3D(pt.x, pt.y, -pt.z) for pt in m_top.vertices) faces = tuple(face[::-1] for face in m_top.faces) m_bottom = Mesh3D(verts, faces) v_bottom = tuple(Vector3D(v.x, v.y, -v.z) for v in v_top) # join everything together patch_mesh = Mesh3D.join_meshes([m_top, m_bottom]) patch_vectors = v_top + v_bottom return patch_mesh, patch_vectors def __repr__(self): """ViewSphere representation.""" return 'ViewSphere'
# make a single object that can be reused throughout the library view_sphere = ViewSphere()