# coding: utf-8
"""Clerestory Parameters with instructions for generating clerestory 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 _ClerestoryParameterBase(object):
"""Base object for all Clerestory parameters.
This object records the minimum number of the methods that must be overwritten
on a clerestory 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 clerestory ' \
'parameter user_data. Got {}.'.format(type(value))
self._user_data = value
def area_from_face(self, face):
"""Get the clerestory area generated by these parameters from a Room2D Face3D."""
return 0
def add_clerestory_to_face(self, face, tolerance=0.01):
"""Add Apertures to a Honeybee Roof Face using these Clerestory Parameters."""
pass
def scale(self, factor):
"""Get a scaled version of these ClerestoryParameters.
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 ClerestoryParameterBase from a dictionary.
.. code-block:: python
{
"type": "ClerestoryParameterBase"
}
"""
assert data['type'] == 'ClerestoryParameterBase', \
'Expected ClerestoryParameterBase dictionary. Got {}.'.format(data['type'])
return cls()
def to_dict(self):
"""Get ClerestoryParameterBase as a dictionary."""
return {'type': 'ClerestoryParameterBase'}
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 ClerestoryParameters."""
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 ClerestoryParameters 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 ClerestoryParameters.
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
ClerestoryParameters 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 _ClerestoryParameterBase()
def __repr__(self):
return 'ClerestoryParameterBase'
[docs]
class DetailedClerestory(_ClerestoryParameterBase):
"""Instructions for detailed clerestory windows, defined by 2D Polygons.
Args:
base_line: A ladybug_geometry LineSegment2D noting where on the floor
plan of the model the clerestory window polygons are located.
This establishes the plane and domain in which the clerestory
geometries exist.
elevation: A number for the Z-coordinate that places the base_line and
the corresponding clerestory window polygons in 3D space. This
elevation value should be below all of the 3D clerestory window
geometries and sets the origin of the plane in which the
clerestory geometries exist.
polygons: An array of ladybug_geometry Polygon2D objects within the plane
of the base_line with one polygon for each clerestory window. The base_line
plane is assumed to have an origin at the end 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 clerestory 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 DetailedClerestory."""
_ClerestoryParameterBase.__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 clerestory polygon. Got {}'.format(type(polygon))
assert len(polygons) != 0, \
'There must be at least one polygon to use DetailedClerestory.'
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 ' \
'DetailedClerestorys.are_doors. Got {}'.format(type(is_dr))
assert len(are_doors) == len(polygons), \
'Length of DetailedClerestory.are_doors ({}) does not match the length ' \
'of DetailedClerestory.polygons ({}).'.format(
len(are_doors), len(polygons))
self._are_doors = are_doors
[docs]
@classmethod
def from_honeybee(cls, sub_faces):
"""Create DetailedClerestory from an array of Honeybee Apertures and Doors.
Args:
sub_faces: A list of Honeybee Apertures and/or Doors to be converted
to Dragonfly DetailedClerestory. These Apertures and Doors
must all be within the same plane for the resulting
DetailedClerestory 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)
sf_ud = sf.user_data
if sf_ud is not None and '__exist_count__' in sf_ud:
sf_ud['__exist_count__'] += 1
sf_id = '{}__{}'.format(
sf.identifier, sf_ud['__exist_count__'])
else:
sf_id = sf.identifier
if sf_ud is None:
sf.user_data = {'__exist_count__': 1}
else:
sf.user_data['__exist_count__'] = 1
user_dt['identifier'].append(sf_id)
if sf.user_data is not None:
for key, val in sf.user_data.items():
if key == '__exist_count__':
continue
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 DetailedClerestory from Face3Ds.
Args:
face3ds: A list of Face3D objects for the detailed clerestory 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 clerestory 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 clerestory 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 clerestory."""
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 clerestory window polygons in 3D space."""
return self._base_line_3d
@property
def base_plane(self):
"""Get LineSegment3D that places the clerestory 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 clerestory geometries that are overlapping.
This is used to create helper_geometry for the case of invalid
clerestory 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 clerestory 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 clerestory geometries that are self-intersecting.
This is used to create helper_geometry for the case of invalid
clerestory 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 clerestory 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 = '({} clerestory 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 clerestory 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 'Clerestory 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 clerestory 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 clerestory 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 clerestory windows [{}] is greater than the ' \
'area of the parent wall [{}].'.format(win_area, total_area)
# next, check to be sure that no clerestory is out of the roof boundary
msg_template = 'Clerestory 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 = DetailedClerestory(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_clerestory_to_face(self, face, tolerance=0.01):
"""Add Apertures/Doors to a Honeybee Face using these Clerestory 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:
s_geo = s_geo.offset_edges(tolerance)
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 DetailedClerestory 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 DetailedClerestory.
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 DetailedClerestory 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 DetailedClerestory 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 clerestory 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 clerestory 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: # clerestory 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 clerestory geometry flush if they lie within frame_distance.
This is useful for translating between interfaces that expect the clerestory
frame to be included within from the geometry.
Args:
frame_distance: Distance with which the edges of each clerestory window
will be moved in order to make them flush with neighboring windows.
offset_boundary: Boolean to note whether the outer boundary of clerestory
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
clerestory geometries to account for the frame rather than just making
the clerestory 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 clerestory
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 clerestorys 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 clerestory group, generate axes for aligning them flush
flush_polys = []
for ply_grp in grouped_polys:
# if the group has only one clerestory, 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 clerestory 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 clerestory 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 clerestory 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 clerestory 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 clerestory parameters
new_c_par = None
if len(new_polygons) != 0:
new_c_par = DetailedClerestory(
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 clerestory 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 clerestory polygons that are close to one another into a single polygon.
This can be used to create a simpler set of clerestory 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 clerestory 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 clerestory 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)
if len(new_polys) == 0:
return None
self._reassign_are_doors(new_polys, tolerance)
self._polygons = tuple(new_polys)
return self
[docs]
def merge_to_bounding_rectangle(self, tolerance=0.01):
"""Merge clerestory 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 clerestory 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 = DetailedClerestory(
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 clerestory parameters with small geometries removed.
Args:
area_threshold: A number for the area below which a clerestory polygon
will be removed.
"""
# evaluate the small clerestory 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 clerestory parameters
new_c_par = None
if len(new_polygons) != 0:
new_c_par = DetailedClerestory(
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 DetailedClerestory from a dictionary.
Args:
data: A dictionary in the format below.
.. code-block:: python
{
"type": "DetailedClerestory",
"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'] == 'DetailedClerestory', \
'Expected DetailedClerestory 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 DetailedClerestory as a dictionary."""
base = {
'type': 'DetailedClerestory',
'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):
if len(self._polygons) == 0:
return None
new_s = DetailedClerestory(
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, DetailedClerestory) 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 'DetailedClerestory: [{} windows]'.format(len(self._polygons))