Source code for dragonfly.clearstoryparameter

# coding: utf-8
"""Clearstory Parameters with instructions for generating clearstory windows."""
from __future__ import division
import math
import sys
if (sys.version_info < (3, 0)):  # python 2
    from itertools import izip as zip  # python 2

from ladybug_geometry.geometry2d import Point2D, Vector2D, LineSegment2D, Polygon2D
from ladybug_geometry.geometry3d import Vector3D, Point3D, LineSegment3D, Plane, Face3D
from ladybug_geometry.intersection2d import closest_point2d_on_line2d
from ladybug_geometry.bounding import bounding_rectangle, bounding_domain_z

from honeybee.typing import float_in_range, clean_string
from honeybee.aperture import Aperture
from honeybee.door import Door


class _ClearstoryParameterBase(object):
    """Base object for all Clearstory parameters.

    This object records the minimum number of the methods that must be overwritten
    on a clearstory parameter object for it to be successfully be applied in
    dragonfly workflows.
    """
    __slots__ = ('_user_data',)

    def __init__(self):
        self._user_data = None

    @property
    def user_data(self):
        """Get or set an optional dictionary for additional meta data for this object.

        This will be None until it has been set. All keys and values of this
        dictionary should be of a standard Python type to ensure correct
        serialization of the object to/from JSON (eg. str, float, int, list dict)
        """
        return self._user_data

    @user_data.setter
    def user_data(self, value):
        if value is not None:
            assert isinstance(value, dict), 'Expected dictionary for clearstory ' \
                'parameter user_data. Got {}.'.format(type(value))
        self._user_data = value

    def area_from_face(self, face):
        """Get the clearstory area generated by these parameters from a Room2D Face3D."""
        return 0

    def add_clearstory_to_face(self, face, tolerance=0.01):
        """Add Apertures to a Honeybee Roof Face using these Clearstory Parameters."""
        pass

    def scale(self, factor):
        """Get a scaled version of these ClearstoryParameters.

        This method is called within the scale methods of the Room2D.

        Args:
            factor: A number representing how much the object should be scaled.
        """
        return self

    @classmethod
    def from_dict(cls, data):
        """Create ClearstoryParameterBase from a dictionary.

        .. code-block:: python

            {
            "type": "ClearstoryParameterBase"
            }
        """
        assert data['type'] == 'ClearstoryParameterBase', \
            'Expected ClearstoryParameterBase dictionary. Got {}.'.format(data['type'])
        return cls()

    def to_dict(self):
        """Get ClearstoryParameterBase as a dictionary."""
        return {'type': 'ClearstoryParameterBase'}

    def duplicate(self):
        """Get a copy of this object."""
        return self.__copy__()

    def ToString(self):
        return self.__repr__()

    def _add_user_data(self, new_win_par):
        """Add copies of this object's user_data to new ClearstoryParameters."""
        if self.user_data is not None:
            for w_par in new_win_par:
                w_par.user_data = self.user_data.copy()

    def _apply_user_data_to_honeybee(self, sub_faces, clean_data=None):
        """Apply the ClearstoryParameters user_data to generated honeybee objects.

        Args:
            sub_faces: An array of Honeybee Apertures or Doors to which user_data
                will be applied from this ClearstoryParameters.
            clean_data: An optional dictionary of user_data to be used in place
                of the currently assigned user_data. This is useful when not all
                ClearstoryParameters are able to be applied to the generated Honeybee
                objects. When None, the self.user_data will be used. (Default: None).
        """
        app_data = self.user_data if clean_data is None else clean_data
        if app_data is not None:
            for i, sub_f in enumerate(sub_faces):
                u_dict = {}
                for key, val in app_data.items():
                    if isinstance(val, (list, tuple)) and len(val) != 0:
                        if key == 'identifier' and len(val) == len(sub_faces):
                            sub_f.identifier = clean_string(str(val[i]))
                        if key != 'identifier':
                            try:
                                u_dict[key] = val[i]
                            except IndexError:  # use longest list logic
                                u_dict[key] = val[-1]
                    else:
                        if key != 'identifier':
                            u_dict[key] = val
                sub_f.user_data = u_dict

    def __copy__(self):
        return _ClearstoryParameterBase()

    def __repr__(self):
        return 'ClearstoryParameterBase'


[docs] class DetailedClearstory(_ClearstoryParameterBase): """Instructions for detailed clearstory windows, defined by 2D Polygons. Args: base_line: A ladybug_geometry LineSegment2D noting where on the floor plan of the model the clearstory window polygons are located. This establishes the plane and domain in which the clearstory geometries exist. elevation: A number for the Z-coordinate that places the base_line and the corresponding clearstory window polygons in 3D space. This elevation value should be below all of the 3D clearstory window geometries and sets the origin of the plane in which the clearstory geometries exist. polygons: An array of ladybug_geometry Polygon2D objects within the plane of the base_line with one polygon for each clearstory window. The base_line plane is assumed to have an origin at the first point of the line segment and an X-axis extending along the length of the segment. The plane's Y-axis always points upwards. Therefore, both X and Y values of each point in the polygon should always be positive. are_doors: An array of booleans that align with the polygons and note whether each of the polygons represents a door out onto a roof or balcony (True) or a clearstory window (False). If None, it will be assumed that all polygons represent windows and they will be translated to Apertures in any resulting Honeybee model. (Default: None). Properties: * base_line * elevation * polygons * are_doors * user_data * base_line_3d * base_plane """ __slots__ = ('_base_line_3d', '_polygons', '_are_doors') def __init__(self, base_line, elevation, polygons, are_doors=None): """Initialize DetailedClearstory.""" _ClearstoryParameterBase.__init__(self) # add the user_data # process the base_line assert isinstance(base_line, LineSegment2D), \ 'Expected ladybug_geometry LineSegment2D. Got {}'.format(type(base_line)) elevation = float_in_range(elevation, input_name='elevation') origin = Point3D(base_line.p.x, base_line.p.y, elevation) x_axis = Vector3D(base_line.v.x, base_line.v.y, 0) self._base_line_3d = LineSegment3D(origin, x_axis) # process the polygons if not isinstance(polygons, tuple): polygons = tuple(polygons) for polygon in polygons: assert isinstance(polygon, Polygon2D), \ 'Expected Polygon2D for clearstory polygon. Got {}'.format(type(polygon)) assert len(polygons) != 0, \ 'There must be at least one polygon to use DetailedClearstory.' self._polygons = polygons if are_doors is None: self._are_doors = (False,) * len(polygons) else: if not isinstance(are_doors, tuple): are_doors = tuple(are_doors) for is_dr in are_doors: assert isinstance(is_dr, bool), 'Expected booleans for ' \ 'DetailedClearstorys.are_doors. Got {}'.format(type(is_dr)) assert len(are_doors) == len(polygons), \ 'Length of DetailedClearstory.are_doors ({}) does not match the length ' \ 'of DetailedClearstory.polygons ({}).'.format( len(are_doors), len(polygons)) self._are_doors = are_doors
[docs] @classmethod def from_honeybee(cls, sub_faces): """Create DetailedClearstory from an array of Honeybee Apertures and Doors. Args: sub_faces: A list of Honeybee Apertures and/or Doors to be converted to Dragonfly DetailedClearstory. These Apertures and Doors must all be within the same plane for the resulting DetailedClearstory object to be valid. """ # get the base plane from the subface geometry face3ds = [sf.geometry for sf in sub_faces] base_plane, base_line, elevation = cls._evaluate_face3d_base_plane(face3ds) # convert all of the subface geometry to be polygons in the base_plane polygons, are_doors, user_dt = [], [], {'identifier': []} for sf in sub_faces: verts2d = tuple(base_plane.xyz_to_xy(pt) for pt in sf.geometry.boundary) polygons.append(Polygon2D(verts2d)) isd = True if isinstance(sf, Door) and not sf.is_glass else False are_doors.append(isd) user_dt['identifier'].append(sf.identifier) if sf.user_data is not None: for key, val in sf.user_data.items(): try: user_dt[key].append(val) except KeyError: # first time attribute user_dt[key] = [val] clear_par = cls(base_line, elevation, polygons, are_doors) for key, val in user_dt.items(): if len(val) != len(polygons): # not a key that all objects have user_dt.pop(key) clear_par.user_data = user_dt return clear_par
[docs] @classmethod def from_face3ds(cls, face3ds, are_doors=None): """Create DetailedClearstory from Face3Ds. Args: face3ds: A list of Face3D objects for the detailed clearstory windows. are_doors: An array of booleans that align with the face3ds and note whether each of the polygons represents a door (True) or a window (False). If None, it will be assumed that all polygons represent windows and they will be translated to Apertures in any resulting Honeybee model. (Default: None). """ # get the base plane from the subface geometry base_plane, base_line, elevation = cls._evaluate_face3d_base_plane(face3ds) # convert all of the Face3Ds to be polygons in the base_plane polygons = [] for geo in face3ds: verts2d = tuple(base_plane.xyz_to_xy(pt) for pt in geo.boundary) polygons.append(Polygon2D(verts2d)) return cls(base_line, elevation, polygons, are_doors)
@staticmethod def _evaluate_face3d_base_plane(face3ds): """Get the base plane to be used to convert Face3Ds into clearstory polygons.""" # get a plane derived from the first geometry with an upward Y ref_plane = face3ds[0].plane elevation, max_z = bounding_domain_z(face3ds) ref_plane = Plane(ref_plane.n, Point3D(ref_plane.o.x, ref_plane.o.y, elevation)) if ref_plane.y.z < 0: # ensure the Y-axis of the plane is pointing up ref_plane = ref_plane.rotate(ref_plane.n, math.pi, ref_plane.o) # use the bounding rectangle in the plane to place an origin at lower left corner point_2ds = [] for geo in face3ds: for pt3 in geo.boundary: point_2ds.append(ref_plane.xyz_to_xy(pt3)) min_pt_2d, max_pt_2d = bounding_rectangle(point_2ds) origin_3d = ref_plane.xy_to_xyz(min_pt_2d) max_pt_3d = ref_plane.xy_to_xyz(max_pt_2d) seg_end_pt = Point3D(max_pt_3d.x, max_pt_3d.y, elevation) bl = LineSegment3D.from_end_points(seg_end_pt, origin_3d) base_plane = Plane(n=ref_plane.n, o=origin_3d) if base_plane.y.z < 0: # ensure the Y-axis of the plane is pointing up base_plane = base_plane.rotate(base_plane.n, math.pi, base_plane.o) base_line = LineSegment2D.from_array([(bl.p1.x, bl.p1.y), (bl.p2.x, bl.p2.y)]) return base_plane, base_line, elevation @property def base_line(self): """Get LineSegment2D that places the clearstory window polygons in plan.""" bl3 = self._base_line_3d return LineSegment2D(Point2D(bl3.p.x, bl3.p.y), Vector2D(bl3.v.x, bl3.v.y)) @property def elevation(self): """Get the Z-coordinate that places the base_line in 3D space.""" return self._base_line_3d.p.z @property def polygons(self): """Get an array of Polygon2Ds with one polygon for each clearstory.""" return self._polygons @property def are_doors(self): """Get an array of booleans that note whether each polygon is a door.""" return self._are_doors @property def base_line_3d(self): """Get LineSegment3D that places the clearstory window polygons in 3D space.""" return self._base_line_3d @property def base_plane(self): """Get LineSegment3D that places the clearstory window polygons in 3D space.""" origin, x_axis = self._base_line_3d.p2, self._base_line_3d.v normal = Vector3D(-x_axis.y, x_axis.x, 0) bp = Plane(n=normal, o=origin, x=x_axis) if bp.y.z < 0: bp = bp.rotate(bp.n, math.pi, bp.o) return bp
[docs] def area(self): """Get the sub-face area generated by these parameters.""" return sum(polygon.area for polygon in self._polygons)
[docs] def aperture_area(self): """Get the Aperture area generated by these parameters.""" return sum(poly.area for poly, isd in zip(self.polygons, self.are_doors) if not isd)
[docs] def overlapping_geometries(self, tolerance=0.01): """Get Face3D representing clearstory geometries that are overlapping. This is used to create helper_geometry for the case of invalid clearstory parameters. Args: tolerance: The minimum distance that two polygons must overlap in order for them to be considered overlapping and invalid. (Default: 0.01, suitable for objects in meters). Returns: A list of Face3D representing overlapping clearstory geometries. This list will be empty if none of the geometries overlap. """ # group the polygons according to their overlaps grouped_polys = Polygon2D.group_by_overlap(self.polygons, tolerance) # build Face3D any polygons that overlap overlap_geos = [] for poly_group in grouped_polys: if len(poly_group) > 1: for poly in poly_group: pt3d = tuple(self.base_plane.xy_to_xyz(pt) for pt in poly) overlap_geos.append(Face3D(pt3d)) return overlap_geos
[docs] def self_intersecting_geometries(self, tolerance=0.01): """Get Face3D representing clearstory geometries that are self-intersecting. This is used to create helper_geometry for the case of invalid clearstory parameters. Args: tolerance: The minimum distance between a vertex coordinates where they are considered equivalent. (Default: 0.01, suitable for objects in meters). Returns: A list of Face3D representing self-intersecting clearstory geometries. This list will be empty if none of the geometries overlap. """ # build Face3D any polygons that self-intersect int_geos = [] for polygon in self.polygons: if polygon.is_self_intersecting: new_geo = polygon.remove_colinear_vertices(tolerance) if new_geo.is_self_intersecting: pt3d = tuple(self.base_plane.xy_to_xyz(pt) for pt in polygon) int_geos.append(Face3D(pt3d)) return int_geos
[docs] def check_overlaps(self, tolerance=0.01): """Check whether any polygons overlap with one another. Args: tolerance: The minimum distance that two polygons must overlap in order for them to be considered overlapping and invalid. (Default: 0.01, suitable for objects in meters). Returns: A string with the message. Will be an empty string if valid. """ # group the polygons according to their overlaps grouped_polys = Polygon2D.group_by_overlap(self.polygons, tolerance) # report any polygons that overlap if not all(len(g) == 1 for g in grouped_polys): base_msg = '({} clearstory geometries overlap one another)' all_msg = [] for p_group in grouped_polys: if len(p_group) != 1: all_msg.append(base_msg.format(len(p_group))) return ' '.join(all_msg) return ''
[docs] def check_self_intersecting(self, tolerance=0.01): """Check whether any polygons in these clearstory parameters are self intersecting. Args: tolerance: The minimum distance between a vertex coordinates where they are considered equivalent. (Default: 0.01, suitable for objects in meters). Returns: A string with the message. Will be an empty string if valid. """ self_int_i = [] for i, polygon in enumerate(self.polygons): if polygon.is_self_intersecting: try: new_geo = polygon.remove_colinear_vertices(tolerance) if new_geo.is_self_intersecting: self_int_i.append(str(i)) except AssertionError: self_int_i.append(str(i)) if len(self_int_i) != 0: return 'Clearstory polygons with the following indices are ' \ 'self-intersecting: ({})'.format(' '.join(self_int_i)) return ''
[docs] def check_valid_for_face(self, face): """Check that these clearstory parameters are valid for a given Face3D. Args: face: A vertical roof-generated Face3D to which these parameters are applied. Returns: A string with the message. Will be an empty string if valid. """ # first check that the total clearstory area isn't larger than the roof total_area = face.area win_area = self.area_from_face(face) if win_area >= total_area: return 'Total area of clearstory windows [{}] is greater than the ' \ 'area of the parent wall [{}].'.format(win_area, total_area) # next, check to be sure that no clearstory is out of the roof boundary msg_template = 'Clearstory polygon {} is outside the range allowed ' \ 'by the parent wall.' verts2d = tuple(self.base_plane.xyz_to_xy(pt) for pt in face.boundary) parent_poly, parent_holes = Polygon2D(verts2d), None if face.has_holes: parent_holes = tuple( Polygon2D(self.base_plane.xyz_to_xy(pt) for pt in hole) for hole in face.holes ) for i, p_gon in enumerate(self.polygons): if not self._is_sub_polygon(p_gon, parent_poly, parent_holes): return msg_template.format(i) return ''
[docs] def remove_doors(self): """Get a version of this object with all door polygons removed.""" return self._remove_sub_faces(False)
[docs] def remove_windows(self): """Get a version of this object with all window polygons removed.""" return self._remove_sub_faces(True)
def _remove_sub_faces(self, windows=False): """Remove a type of subface from this object (either windows or doors).""" new_polygons, kept_i = [], [] if windows: for i, (poly, is_dr) in enumerate(zip(self.polygons, self.are_doors)): if is_dr: new_polygons.append(poly) kept_i.append(i) else: for i, (poly, is_dr) in enumerate(zip(self.polygons, self.are_doors)): if not is_dr: new_polygons.append(poly) kept_i.append(i) # create the final window parameters new_s_par = None if len(new_polygons) != 0: are_doors = [False] * len(new_polygons) \ if windows else [True] * len(new_polygons) new_s_par = DetailedClearstory(new_polygons, are_doors) # update user_data lists if some windows were not added if new_s_par is not None and self.user_data is not None: clean_u = self.user_data if len(new_polygons) != len(self.polygons): clean_u = {} for key, val in self.user_data.items(): if isinstance(val, (list, tuple)) and len(val) >= len(self.polygons): clean_u[key] = [val[j] for j in kept_i] else: clean_u[key] = val new_s_par.user_data = clean_u return new_s_par
[docs] def add_clearstory_to_face(self, face, tolerance=0.01): """Add Apertures/Doors to a Honeybee Face using these Clearstory Parameters. Args: face: A honeybee-core Face object. tolerance: The maximum difference between point values for them to be considered distinct. (Default: 0.01, suitable for objects in meters). """ # get the polygons that represent the vertical face from the RoofSpecification base_plane = self.base_plane verts2d = tuple(base_plane.xyz_to_xy(pt) for pt in face.geometry.boundary) parent_poly, parent_holes = Polygon2D(verts2d), None if face.geometry.has_holes: parent_holes = tuple( Polygon2D(base_plane.xyz_to_xy(pt) for pt in hole) for hole in face.geometry.holes ) # loop through each polygon and create its geometry sub_faces, kept_i, sub_count = [], [], 1 for i, (polygon, isd) in enumerate(zip(self.polygons, self.are_doors)): poly_relation = self._sub_poly_relation( polygon, parent_poly, parent_holes, tolerance) if poly_relation == -1: continue pt3d = tuple(base_plane.xy_to_xyz(p) for p in polygon) if None in pt3d: continue s_geos = [Face3D(pt3d)] if poly_relation == 0: # find the boolean difference par_geo = face.geometry bool_int = Face3D.coplanar_intersection( par_geo, s_geos[0], tolerance, math.radians(1)) if bool_int is None or len(bool_int) == 0: # boolean intersection failed continue # offset the result of the boolean intersection from the edge s_geos = [] for res in bool_int: new_pts = [res.plane.xy_to_xyz(pt2) for pt2 in res.boundary_polygon2d.offset(tolerance * 2)] s_geos.append(Face3D(new_pts)) for s_geo in s_geos: if isd: sub_f = Door('{}_Door{}'.format(face.identifier, sub_count), s_geo) face.add_door(sub_f) else: sub_f = Aperture('{}_Glz{}'.format(face.identifier, sub_count), s_geo) face.add_aperture(sub_f) sub_faces.append(sub_f) kept_i.append(i) sub_count += 1 # assign user data to the resulting objects clean_u = self.user_data if self.user_data is not None: if len(sub_faces) != len(self.polygons): clean_u = {} for key, val in self.user_data.items(): if isinstance(val, (list, tuple)) and len(val) >= len(self.polygons): clean_u[key] = [val[j] for j in kept_i] else: clean_u[key] = val self._apply_user_data_to_honeybee(sub_faces, clean_u)
[docs] def move(self, moving_vec): """Get this DetailedClearstory moved along a vector. Args: moving_vec: A Vector3D with the direction and distance to move the polygon. """ new_c = self.duplicate() new_c._base_line_3d = self._base_line_3d.move(moving_vec) return new_c
[docs] def scale(self, factor, origin=None): """Get a scaled version of this DetailedClearstory. This method is called within the scale methods of the Room2D. Args: factor: A number representing how much the object should be scaled. origin: A ladybug_geometry Point3D representing the origin from which to scale. If None, it will be scaled from the World origin (0, 0, 0). """ new_c = self.duplicate() new_c._base_line_3d = self._base_line_3d.scale(factor, origin) new_c._polygons = tuple(polygon.scale(factor) for polygon in self.polygons) return new_c
[docs] def rotate(self, angle, origin): """Get this DetailedClearstory rotated counterclockwise in the XY plane. Args: angle: An angle in degrees. origin: A ladybug_geometry Point3D for the origin around which the object will be rotated. """ new_c = self.duplicate() new_c._base_line_3d = self._base_line_3d.rotate_xy(math.radians(angle), origin) return new_c
[docs] def reflect(self, plane): """Get a reflected version of this DetailedClearstory across a plane. Args: plane: A ladybug_geometry Plane across which the object will be reflected. """ new_c = self.duplicate() new_c._base_line_3d = self._base_line_3d.reflect(plane.n, plane.o) return new_c
[docs] def offset(self, offset_distance, tolerance=0.01): """Offset the edges of all clearstory polygons by a certain distance. This is useful for translating between interfaces that expect the window frame to be included within or excluded from the geometry. Note that this operation can often create polygons that collide with one another or extend past the parent Face. So it may be desirable to run the union_overlaps method after using this one. Args: offset_distance: Distance with which the edges of each polygon will be offset from the original geometry. Positive values will offset the geometry outwards and negative values will offset the geometries inwards. tolerance: The minimum difference between point values for them to be considered the distinct. (Default: 0.01, suitable for objects in meters). """ offset_polys, offset_are_doors = [], [] for polygon, isd in zip(self.polygons, self.are_doors): try: poly = polygon.remove_colinear_vertices(tolerance) off_poly = poly.offset(-offset_distance, False) if not off_poly.is_self_intersecting: offset_polys.append(off_poly) else: # polygon became self-intersecting after offset offset_polys.append(poly) offset_are_doors.append(isd) except AssertionError: # degenerate window to ignore pass self._polygons = tuple(offset_polys) self._are_doors = tuple(offset_are_doors)
[docs] def offset_polygons_for_face(self, face_3d, offset_distance=0.05, tolerance=0.01): """Offset the polygons until all vertices are inside the boundaries of a Face3D. Args: face_3d: A Face3D representing the vertical wall geometry of a RoofSpecification to which these clearstory parameters are assigned. offset_distance: Distance from the edge of the face_3d that the polygons will be offset to. (Default: 0.05, suitable for objects in meters). tolerance: The maximum difference between point values for them to be considered distinct. (Default: 0.01, suitable for objects in meters). """ # get the polygons that represent the vertical face from the RoofSpecification base_plane = self.base_plane verts2d = tuple(base_plane.xyz_to_xy(pt) for pt in face_3d.boundary) parent_poly, parent_holes = Polygon2D(verts2d), None if face_3d.has_holes: parent_holes = tuple( Polygon2D(base_plane.xyz_to_xy(pt) for pt in hole) for hole in face_3d.holes ) # loop through the polygons and offset them if they are not correctly bounded new_polygons, new_are_doors = [], [] for polygon, isd in zip(self.polygons, self.are_doors): if not self._is_sub_polygon(polygon, parent_poly, parent_holes): # find the boolean intersection of the polygon with the room sub_face = Face3D([base_plane.xy_to_xyz(pt) for pt in polygon]) bool_int = Face3D.coplanar_intersection( face_3d, sub_face, tolerance, math.radians(1)) if bool_int is None: # clearstory completely outside parent continue # offset the result of the boolean intersection from the edge parent_edges = face_3d.boundary_segments if face_3d.holes is None \ else face_3d.boundary_segments + \ tuple(seg for hole in face_3d.hole_segments for seg in hole) for new_f in bool_int: new_pts_2d = [] for pt in new_f.boundary: for edge in parent_edges: close_pt = edge.closest_point(pt) if pt.distance_to_point(close_pt) < offset_distance: move_vec = edge.v.rotate_xy(math.pi / 2).normalize() move_vec = move_vec * offset_distance pt = pt.move(move_vec) new_pts_2d.append(base_plane.xyz_to_xy(pt)) new_polygons.append(Polygon2D(new_pts_2d)) new_are_doors.append(isd) else: new_polygons.append(polygon) new_are_doors.append(isd) # assign the offset polygons to this object self._polygons = tuple(new_polygons) self._are_doors = tuple(new_are_doors)
[docs] def make_flush(self, frame_distance, offset_boundary=False, tolerance=0.01, angle_tolerance=1.0): """Make the edges of clearstory geometry flush if they lie within frame_distance. This is useful for translating between interfaces that expect the clearstory frame to be included within from the geometry. Args: frame_distance: Distance with which the edges of each clearstory window will be moved in order to make them flush with neighboring windows. offset_boundary: Boolean to note whether the outer boundary of clearstory groups that have been made flush with one another should be offset after all windows within the group have been made flush (True) or the boundary around the group should be left unchanged (False). Set to True when the intended result is more like an offset of clearstory geometries to account for the frame rather than just making the clearstory windows flush. (Default: True). tolerance: The minimum difference between point values for them to be considered the distinct. (Default: 0.01, suitable for objects in meters). angle_tolerance: The max angle difference in degrees that a clearstory segment direction can differ from the X or Y axis before it is excluded from being made flush. (Default: 1). """ # process the inputs used throughout the calculation touch_dist = 2 * frame_distance min_distance = 0.5 * touch_dist merge_distance = 3 * frame_distance a_tol = math.radians(angle_tolerance) x_axis, y_axis = Vector2D(1, 0), Vector2D(0, 1) # first group the clearstorys together if they lie within 2 times frame_distance grouped_polys = Polygon2D.group_by_touching(self.polygons, touch_dist) grouped_are_doors = [] for pg_grp in grouped_polys: for p_gon1 in pg_grp: for p_gon2, isd in zip(self.polygons, self.are_doors): if p_gon1.center.is_equivalent(p_gon2.center, tolerance): grouped_are_doors.append(isd) break else: grouped_are_doors.append(False) # for each clearstory group, generate axes for aligning them flush flush_polys = [] for ply_grp in grouped_polys: # if the group has only one clearstory, use the fast method if len(ply_grp) == 1: if offset_boundary: ply_grp = [ply_grp[0].offset(-frame_distance)] flush_polys.extend(ply_grp) continue # get the common X and Y axes y_axes, _ = Polygon2D.common_axes(ply_grp, y_axis, min_distance, merge_distance, a_tol, None) x_axes, _ = Polygon2D.common_axes(ply_grp, x_axis, min_distance, merge_distance, a_tol, None) # pull the clearstory polygons to the X and Y axes for i, poly in enumerate(ply_grp): ply_grp[i] = self._pull_to_segments(poly, y_axes, touch_dist) for i, poly in enumerate(ply_grp): ply_grp[i] = self._pull_to_segments(poly, x_axes, touch_dist) # offset the outer boundary of the group if requested if offset_boundary: # set up variables to be used to group segments n_vec = Vector2D(0, 1) a_tol = math.radians(5) # tolerance for evaluating parallel lines # get the boundary around the group and offset it grp_bounds = Polygon2D.joined_intersected_boundary(ply_grp, tolerance) pull_segs = [] for bnd in grp_bounds: bnd = bnd.remove_colinear_vertices(tolerance) bnd = bnd.offset(-frame_distance) pull_segs.extend(bnd.segments) # group the lines base on whether they are parallel to one another grouped_lines = {} for lin in pull_segs: azimuth = n_vec.angle_clockwise(lin.v) if azimuth >= math.pi - a_tol: azimuth = azimuth - math.pi for key in grouped_lines.keys(): if key - a_tol < azimuth < key + a_tol: grouped_lines[key].append(lin) break else: grouped_lines[azimuth] = [lin] pull_segs = list(grouped_lines.values()) # pull the clearstory polygons to the X and Y axes for i in range(len(ply_grp)): for seg_grp in pull_segs: ply_grp[i] = self._pull_to_segments( ply_grp[i], seg_grp, touch_dist) # add the group to all of the flush polygons flush_polys.extend(ply_grp) # remove any degenerate polygons created in the process i_to_remove = [] for i, poly in enumerate(flush_polys): try: poly.remove_colinear_vertices(tolerance) except (ValueError, AssertionError): i_to_remove.append(i) # update user_data lists if some windows were not added if self.user_data is not None and len(i_to_remove) != 0: clean_u = {} kept_i = [i for i in range(len(flush_polys)) if i not in i_to_remove] for key, val in self.user_data.items(): if isinstance(val, (list, tuple)) and len(val) >= len(self.polygons): clean_u[key] = [val[j] for j in kept_i] else: clean_u[key] = val self._user_data = clean_u # set the attributes of the object to the new cleaned polygons for del_i in reversed(i_to_remove): flush_polys.pop(del_i) grouped_are_doors.pop(del_i) self._polygons = tuple(flush_polys) self._are_doors = tuple(grouped_are_doors)
@staticmethod def _pull_to_segments(polygon, line_segments, distance): """Pull a Polygon2D to a list of LineSegment2Ds.""" if len(line_segments) == 0: return polygon # nothing to pull to new_vertices = [] for pt in polygon.vertices: dists, c_pts = [], [] for line in line_segments: close_pt = closest_point2d_on_line2d(pt, line) c_pts.append(close_pt) dists.append(pt.distance_to_point(close_pt)) sort_pt = sorted(zip(dists, c_pts), key=lambda pair: pair[0]) if sort_pt[0][0] <= distance: new_vertices.append(sort_pt[0][1]) else: new_vertices.append(pt) return Polygon2D(new_vertices)
[docs] def rectangularize(self, percent_area_change_threshold=None): """Convert clearstory polygons into rectangles. Note that rectangular conversion is done simply by taking the bounding rectangle around each polygon. If this bounding rectangle representation changes the area by more than the percent_area_change_threshold, it will not be converted to a rectangle. Args: percent_area_change_threshold: A positive number for the maximum permitted change in area that is allowed by the operation. For example, setting it to 100 will allow windows to double in size by this operation. Set to None to have all windows rectangularized no matter the change in area that this causes. Setting to zero will have no effect. (Default: None). """ fract_change = percent_area_change_threshold / 100 \ if percent_area_change_threshold is not None else None new_polygons = [] for poly in self.polygons: min_pt, max_pt = poly.min, poly.max pts = (min_pt, Point2D(max_pt.x, min_pt.y), max_pt, Point2D(min_pt.x, max_pt.y)) new_poly = Polygon2D(pts) if fract_change is None or \ new_poly.area - poly.area <= poly.area * fract_change: new_polygons.append(new_poly) else: new_polygons.append(poly) self._polygons = tuple(new_polygons)
[docs] def remove_self_intersecting(self, tolerance=0.01): """Get these clearstory parameters with self intersecting geometries removed. Args: tolerance: The minimum distance between a vertex coordinates where they are considered equivalent. (Default: 0.01, suitable for objects in meters). Returns: A string with the message. Will be an empty string if valid. """ new_polygons, new_are_doors, kept_i = [], [], [] for i, (polygon, isd) in enumerate(zip(self.polygons, self.are_doors)): if not polygon.is_self_intersecting: new_polygons.append(polygon) new_are_doors.append(isd) else: try: new_geo = polygon.remove_colinear_vertices(tolerance) if not new_geo.is_self_intersecting: new_polygons.append(new_geo) new_are_doors.append(isd) kept_i.append(i) except AssertionError: pass # return the final clearstory parameters new_c_par = None if len(new_polygons) != 0: new_c_par = DetailedClearstory( self.base_line, self.elevation, new_polygons, new_are_doors ) # update user_data lists if some windows were not added if new_c_par is not None and self.user_data is not None: clean_u = self.user_data if len(new_polygons) != len(self.polygons): clean_u = {} for key, val in self.user_data.items(): if isinstance(val, (list, tuple)) and len(val) >= len(self.polygons): clean_u[key] = [val[j] for j in kept_i] else: clean_u[key] = val new_c_par.user_data = clean_u return new_c_par
[docs] def union_overlaps(self, tolerance=0.01): """Union any clearstory polygons that overlap with one another. Args: tolerance: The minimum distance that two polygons must overlap in order for them to be considered overlapping. (Default: 0.01, suitable for objects in meters). """ # group the polygons by their overlap grouped_polys = Polygon2D.group_by_overlap(self.polygons, tolerance) # union any of the polygons that overlap if not all(len(g) == 1 for g in grouped_polys): new_polys = [] for p_group in grouped_polys: if len(p_group) == 1: new_polys.append(p_group[0]) else: union_poly = Polygon2D.boolean_union_all(p_group, tolerance) for new_poly in union_poly: new_polys.append(new_poly.remove_colinear_vertices(tolerance)) self._reassign_are_doors(new_polys, tolerance) self._polygons = tuple(new_polys)
[docs] def merge_and_simplify(self, max_separation, tolerance=0.01): """Merge clearstory polygons that are close to one another into a single polygon. This can be used to create a simpler set of clearstory windows that is easier to edit and is in the same location as the original windows. Args: max_separation: A number for the maximum distance between clearstory polygons at which point they will be merged into a single geometry. Typically, this max_separation should be set to a value that is slightly larger than the window frame. Setting this equal to the tolerance will simply join neighboring clearstory windows together. tolerance: The maximum difference between point values for them to be considered distinct. (Default: 0.01, suitable for objects in meters). """ # gather a clean version of the polygons with colinear vertices removed clean_polys = [] for poly in self.polygons: try: clean_polys.append(poly.remove_colinear_vertices(tolerance)) except AssertionError: # degenerate geometry to ignore pass # join the polygons together if max_separation <= tolerance: new_polys = Polygon2D.joined_intersected_boundary( clean_polys, tolerance) else: new_polys = Polygon2D.gap_crossing_boundary( clean_polys, max_separation, tolerance) self._reassign_are_doors(new_polys, tolerance) self._polygons = tuple(new_polys)
[docs] def merge_to_bounding_rectangle(self, tolerance=0.01): """Merge clearstory polygons that touch or overlap with one another to a rectangle. Args: tolerance: The minimum distance from the edge of a neighboring polygon at which a point is considered to touch that polygon. (Default: 0.01, suitable for objects in meters). """ # group the polygons by their overlap grouped_polys = Polygon2D.group_by_touching(self.polygons, tolerance) # union any of the polygons that overlap if not all(len(g) == 1 for g in grouped_polys): new_polys = [] for p_group in grouped_polys: if len(p_group) == 1: new_polys.append(p_group[0]) else: min_pt, max_pt = bounding_rectangle(p_group) rect_verts = ( min_pt, Point2D(max_pt.x, min_pt.y), max_pt, Point2D(min_pt.x, max_pt.y)) rect_poly = Polygon2D(rect_verts) new_polys.append(rect_poly) self._reassign_are_doors(new_polys, tolerance) self._polygons = tuple(new_polys)
[docs] def remove_duplicate_windows(self, tolerance=0.01): """Get a version of these clearstory parameters with duplicate geometries removed. Args: tolerance: The minimum distance between points for them to be considered distinct. (Default: 0.01, suitable for objects in meters). """ # gather the indices of all the duplicates new_polygons, new_are_doors = list(self.polygons), list(self.are_doors) removed_i = set() for i, poly_1 in enumerate(new_polygons): try: for j, poly_2 in enumerate(new_polygons[i + 1:]): if poly_1.center.is_equivalent(poly_2.center, tolerance): if all(poly_1.is_point_on_edge(pt, tolerance) for pt in poly_2): removed_i.add(i + j + 1) except IndexError: pass # we have reached the end of the list of rooms # remove the items from the new_polygons and new_are_doors lists kept_i = [i for i in range(len(new_polygons)) if i not in removed_i] for ri in reversed(sorted(removed_i)): new_polygons.pop(ri) new_are_doors.pop(ri) # return the final window parameters new_c_par = DetailedClearstory( self.base_line, self.elevation, new_polygons, new_are_doors ) # update user_data lists if some windows were not added if self.user_data is not None: clean_u = self.user_data if len(new_polygons) != len(self.polygons): clean_u = {} for key, val in self.user_data.items(): if isinstance(val, (list, tuple)) and len(val) >= len(self.polygons): clean_u[key] = [val[j] for j in kept_i] else: clean_u[key] = val new_c_par.user_data = clean_u return new_c_par
[docs] def remove_small_windows(self, area_threshold): """Get a version of these clearstory parameters with small geometries removed. Args: area_threshold: A number for the area below which a clearstory polygon will be removed. """ # evaluate the small clearstory windows new_polygons, new_are_doors, kept_i = [], [], [] for i, (poly, is_dr) in enumerate(zip(self.polygons, self.are_doors)): if poly.area > area_threshold: new_polygons.append(poly) new_are_doors.append(is_dr) kept_i.append(i) # return the final clearstory parameters new_c_par = None if len(new_polygons) != 0: new_c_par = DetailedClearstory( self.base_line, self.elevation, new_polygons, new_are_doors ) # update user_data lists if some windows were not added if new_c_par is not None and self.user_data is not None: clean_u = self.user_data if len(new_polygons) != len(self.polygons): clean_u = {} for key, val in self.user_data.items(): if isinstance(val, (list, tuple)) and len(val) >= len(self.polygons): clean_u[key] = [val[j] for j in kept_i] else: clean_u[key] = val new_c_par.user_data = clean_u return new_c_par
[docs] @classmethod def from_dict(cls, data): """Create DetailedClearstory from a dictionary. Args: data: A dictionary in the format below. .. code-block:: python { "type": "DetailedClearstory", "base_line": [(10, 5), (10, 10)], "elevation": 5, "polygons": [((0.5, 0.5), (2, 0.5), (2, 2), (0.5, 2)), ((3, 1), (4, 1), (4, 2))], "are_doors": [False, False] } """ assert data['type'] == 'DetailedClearstory', \ 'Expected DetailedClearstory dictionary. Got {}.'.format(data['type']) are_doors = data['are_doors'] if 'are_doors' in data else None new_c_par = cls( LineSegment2D.from_array(data['base_line']), data['elevation'], tuple(Polygon2D(tuple(Point2D.from_array(pt) for pt in poly)) for poly in data['polygons']), are_doors ) if 'user_data' in data and data['user_data'] is not None: new_c_par.user_data = data['user_data'] return new_c_par
[docs] def to_dict(self): """Get DetailedClearstory as a dictionary.""" base = { 'type': 'DetailedClearstory', 'base_line': self.base_line.to_array(), 'elevation': self.elevation, 'polygons': [[pt.to_array() for pt in poly] for poly in self.polygons], 'are_doors': self.are_doors } if self.user_data is not None: base['user_data'] = self.user_data return base
def _reassign_are_doors(self, new_polys, tolerance=0.01): """Reset the are_doors property using a set of new polygons.""" if len(new_polys) != len(self._polygons): # match the new polygons to the existing ones new_are_doors, kept_i = [], [] for n_poly in new_polys: np_center = n_poly.center if n_poly.is_convex else \ n_poly.pole_of_inaccessibility(tolerance) for i, (o_poly, is_door) in enumerate(zip(self.polygons, self.are_doors)): if o_poly.is_point_inside_bound_rect(np_center): new_are_doors.append(is_door) kept_i.append(i) break else: new_are_doors.append(False) kept_i.append(0) self._are_doors = tuple(new_are_doors) # update user_data lists if some windows were not added if self.user_data is not None: clean_u = {} for key, val in self.user_data.items(): if isinstance(val, (list, tuple)) and len(val) >= len(self.polygons): clean_u[key] = [val[j] for j in kept_i] else: clean_u[key] = val self.user_data = clean_u @staticmethod def _is_sub_polygon(sub_poly, parent_poly, parent_holes=None): """Check if a sub-polygon is valid for a given assumed parent polygon. Args: sub_poly: A sub-Polygon2D for which sub-face equivalency will be tested. parent_poly: A parent Polygon2D. parent_holes: An optional list of Polygon2D for any holes that may exist in the parent polygon. (Default: None). """ if parent_holes is None: return parent_poly.is_polygon_inside(sub_poly) else: if not parent_poly.is_polygon_inside(sub_poly): return False for hole_poly in parent_holes: if not hole_poly.is_polygon_outside(sub_poly): return False return True @staticmethod def _sub_poly_relation(sub_polygon, parent_poly, parent_holes, tolerance): """Check the relationship between a sub_polygon and a parent polygon. Args: sub_polygon: A Polygon2D which will be checked if it lies inside the parent. parent_poly: A parent Polygon2D. parent_holes: An optional list of Polygon2D for any holes that may exist in the parent polygon. (Default: None). """ base_relation = parent_poly.polygon_relationship(sub_polygon, tolerance) if parent_holes is None: return base_relation else: if base_relation == -1: return -1 for hole_poly in parent_holes: hole_relation = hole_poly.polygon_relationship(sub_polygon, tolerance) if hole_relation == 1: return -1 return base_relation @staticmethod def _is_sub_point(sub_point, parent_poly, parent_holes=None): """Check if a point lies inside a parent polygon. Args: sub_point: A Point2D which will be checked if it lies inside the parent. parent_poly: A parent Polygon2D. parent_holes: An optional list of Polygon2D for any holes that may exist in the parent polygon. (Default: None). """ if parent_holes is None: return parent_poly.is_point_inside(sub_point) else: if not parent_poly.is_point_inside(sub_point): return False for hole_poly in parent_holes: if hole_poly.is_point_inside(sub_point): return False return True def __len__(self): return len(self._polygons) def __getitem__(self, key): return self._polygons[key] def __iter__(self): return iter(self._polygons) def __copy__(self): new_s = DetailedClearstory( self.base_line, self.elevation, self._polygons, self._are_doors ) new_s._user_data = None if self.user_data is None else self.user_data.copy() return new_s def __key(self): """A tuple based on the object properties, useful for hashing.""" return (hash(self._base_line_3d),) + \ tuple(hash(polygon) for polygon in self._polygons) + self.are_doors def __hash__(self): return hash(self.__key()) def __eq__(self, other): return isinstance(other, DetailedClearstory) and \ len(self._polygons) == len(other._polygons) and \ self._base_line_3d == other._base_line_3d def __ne__(self, other): return not self.__eq__(other) def __repr__(self): return 'DetailedClearstory: [{} windows]'.format(len(self._polygons))