Source code for dragonfly.room2d

# coding: utf-8
"""Dragonfly Room2D."""
from __future__ import division

import math

from ladybug_geometry.geometry2d import Point2D, Vector2D, Ray2D, LineSegment2D, \
    Polyline2D, Polygon2D
from ladybug_geometry.geometry3d import Point3D, Vector3D, Ray3D, LineSegment3D, \
    Plane, Polyline3D, Face3D, Polyface3D
from ladybug_geometry.intersection2d import closest_point2d_between_line2d, \
    closest_point2d_on_line2d
from ladybug_geometry.intersection3d import closest_point3d_on_line3d, \
    closest_point3d_on_line3d_infinite, intersect_line3d_plane_infinite
import ladybug_geometry.boolean as pb
from ladybug_geometry_polyskel.polysplit import perimeter_core_subfaces

from honeybee.typing import float_positive, clean_string, clean_and_id_string
import honeybee.boundarycondition as hbc
from honeybee.boundarycondition import boundary_conditions as bcs
from honeybee.boundarycondition import _BoundaryCondition, Outdoors, Surface, Ground
from honeybee.facetype import Floor, Wall, AirBoundary, RoofCeiling
from honeybee.facetype import face_types as ftyp
from honeybee.door import Door
from honeybee.face import Face
from honeybee.room import Room

from ._base import _BaseGeometry
from .properties import Room2DProperties
import dragonfly.windowparameter as glzpar
from dragonfly.windowparameter import _WindowParameterBase, _AsymmetricBase, \
    SimpleWindowRatio, RectangularWindows, DetailedWindows
import dragonfly.skylightparameter as skypar
from dragonfly.skylightparameter import _SkylightParameterBase, DetailedSkylights, \
    GriddedSkylightArea, GriddedSkylightRatio
import dragonfly.shadingparameter as shdpar
from dragonfly.shadingparameter import _ShadingParameterBase
import dragonfly.writer.room2d as writer


[docs] class Room2D(_BaseGeometry): """A volume defined by an extruded floor plate, representing a single room or space. Args: identifier: Text string for a unique Room2D ID. Must be < 100 characters and not contain any spaces or special characters. floor_geometry: A single horizontal Face3D object representing the floor plate of the Room. Note that this Face3D must be horizontal to be valid. floor_to_ceiling_height: A number for the height above the floor where the ceiling begins. This should be in the same units system as the input floor_geometry. Typical values range from 3 to 5 meters. boundary_conditions: A list of boundary conditions that match the number of segments in the input floor_geometry. These will be used to assign boundary conditions to each of the walls of the Room in the resulting model. If None, all boundary conditions will be Outdoors or Ground depending on whether ceiling of the room is below 0 (the assumed ground plane). Default: None. window_parameters: A list of WindowParameter objects that dictate how the window geometries will be generated for each of the walls. If None, no windows will exist over the entire Room2D. Default: None. shading_parameters: A list of ShadingParameter objects that dictate how the shade geometries will be generated for each of the walls. If None, no shades will exist over the entire Room2D. Default: None. is_ground_contact: A boolean noting whether this Room2D has its floor in contact with the ground. Default: False. is_top_exposed: A boolean noting whether this Room2D has its ceiling exposed to the outdoors. Default: False. tolerance: The maximum difference between z values at which point vertices are considered to be in the same horizontal plane. This is used to check that all vertices of the input floor_geometry lie in the same horizontal floor plane. Default is 0, which will not perform any check. Properties: * identifier * display_name * full_id * floor_geometry * floor_to_ceiling_height * boundary_conditions * window_parameters * shading_parameters * air_boundaries * is_ground_contact * is_top_exposed * has_floor * has_ceiling * skylight_parameters * parent * has_parent * floor_segments * floor_segments_2d * segment_count * segment_normals * floor_height * ceiling_height * volume * floor_area * exterior_wall_area * exterior_aperture_area * is_core * is_perimeter * min * max * center * user_data """ __slots__ = ('_floor_geometry', '_segment_count', '_floor_to_ceiling_height', '_boundary_conditions', '_window_parameters', '_shading_parameters', '_air_boundaries', '_is_ground_contact', '_is_top_exposed', '_has_floor', '_has_ceiling', '_skylight_parameters', '_parent', '_abridged_properties') def __init__(self, identifier, floor_geometry, floor_to_ceiling_height, boundary_conditions=None, window_parameters=None, shading_parameters=None, is_ground_contact=False, is_top_exposed=False, tolerance=0): """A volume defined by an extruded floor plate, representing a single room.""" _BaseGeometry.__init__(self, identifier) # process the identifier # process the floor_geometry assert isinstance(floor_geometry, Face3D), \ 'Expected ladybug_geometry Face3D. Got {}'.format(type(floor_geometry)) if floor_geometry.normal.z >= 0: # ensure upward-facing Face3D self._floor_geometry = floor_geometry else: self._floor_geometry = floor_geometry.flip() # ensure a global 2D origin, which helps in solve adjacency and the dict schema o_pl = Plane(Vector3D(0, 0, 1), Point3D(0, 0, self._floor_geometry.plane.o.z)) self._floor_geometry = Face3D(self._floor_geometry.boundary, o_pl, self._floor_geometry.holes) # check that the floor_geometry lies in the same horizontal plane. if tolerance != 0: z_vals = tuple(pt.z for pt in self._floor_geometry.vertices) assert max(z_vals) - min(z_vals) <= tolerance, 'Not all of Room2D ' \ '"{}" vertices lie within the same horizontal plane.'.format(identifier) # process segment count and floor-to-ceiling height self._segment_count = len(self.floor_segments) self.floor_to_ceiling_height = floor_to_ceiling_height # process the boundary conditions if boundary_conditions is None: bc = bcs.outdoors if self.ceiling_height > 0 else bcs.ground self._boundary_conditions = [bc] * len(self) else: value = self._check_wall_assigned_object( boundary_conditions, 'boundary_conditions') for val in value: assert isinstance(val, _BoundaryCondition), \ 'Expected BoundaryCondition. Got {}'.format(type(value)) self._boundary_conditions = value # process the window and shading parameters self.window_parameters = window_parameters self.shading_parameters = shading_parameters # ensure all wall-assigned objects align with the geometry if it has been flipped if floor_geometry.normal.z < 0: new_bcs, new_win_pars, new_shd_pars = Room2D._flip_wall_assigned_objects( floor_geometry, self._boundary_conditions, self._window_parameters, self._shading_parameters) self._boundary_conditions = new_bcs self._window_parameters = new_win_pars self._shading_parameters = new_shd_pars # process the top and bottom exposure properties self.is_ground_contact = is_ground_contact self.is_top_exposed = is_top_exposed self._has_floor = True self._has_ceiling = True self._skylight_parameters = None self._air_boundaries = None # will be set if it's ever used self._parent = None # _parent will be set when Room2D is added to a Story self._abridged_properties = None # will be set when originating from abridged self._properties = Room2DProperties(self) # properties for extensions
[docs] @classmethod def from_dict(cls, data, tolerance=0, persist_abridged=False): """Initialize a Room2D from a dictionary. Args: data: A dictionary representation of a Room2D object. tolerance: The maximum difference between z values at which point vertices are considered to be in the same horizontal plane. This is used to check that all vertices of the input floor_geometry lie in the same horizontal floor plane. Default is 0, which will not perform any check. persist_abridged: Set to True when the properties of the Room2D dictionary are abridged and you want to ensure that these exact same abridged properties persist into the output of Room2D.to_dict(abridged=True). It is useful when trying to edit the Room2D independently of a Model and there are no plans to edit any extension properties of the Room2D. THIS IS AN ADVANCED OPTION. (Default: False). """ # check the type of dictionary assert data['type'] == 'Room2D', 'Expected Room2D dictionary. ' \ 'Got {}.'.format(data['type']) # re-assemble the floor_geometry bound_verts = [Point3D(pt[0], pt[1], data['floor_height']) for pt in data['floor_boundary']] if 'floor_holes' in data: hole_verts = [[Point3D(pt[0], pt[1], data['floor_height']) for pt in hole] for hole in data['floor_holes']] else: hole_verts = None floor_geometry = Face3D(bound_verts, None, hole_verts) # re-assemble boundary conditions if 'boundary_conditions' in data and data['boundary_conditions'] is not None: b_conditions = [] for bc_dict in data['boundary_conditions']: try: bc_class = getattr(hbc, bc_dict['type']) except AttributeError: raise ValueError( 'Boundary condition "{}" is not supported in this honeybee ' 'installation.'.format(bc_dict['type'])) b_conditions.append(bc_class.from_dict(bc_dict)) else: b_conditions = None # re-assemble window parameters if 'window_parameters' in data and data['window_parameters'] is not None: glz_pars = [] for i, glz_dict in enumerate(data['window_parameters']): if glz_dict is not None: if glz_dict['type'] == 'DetailedWindows': segment = cls.floor_segment_by_index(floor_geometry, i) glz_pars.append(DetailedWindows.from_dict(glz_dict, segment)) else: try: glz_class = getattr(glzpar, glz_dict['type']) except AttributeError: raise ValueError( 'Window parameter "{}" is not recognized.'.format( glz_dict['type'])) glz_pars.append(glz_class.from_dict(glz_dict)) else: glz_pars.append(None) else: glz_pars = None # re-assemble shading parameters if 'shading_parameters' in data and data['shading_parameters'] is not None: shd_pars = [] for shd_dict in data['shading_parameters']: if shd_dict is not None: try: shd_class = getattr(shdpar, shd_dict['type']) except AttributeError: raise ValueError( 'Shading parameter "{}" is not recognized.'.format( shd_dict['type'])) shd_pars.append(shd_class.from_dict(shd_dict)) else: shd_pars.append(None) else: shd_pars = None # get the top and bottom exposure properties grnd = data['is_ground_contact'] if 'is_ground_contact' in data else False top = data['is_top_exposed'] if 'is_top_exposed' in data else False flr = data['has_floor'] if 'has_floor' in data else True ceil = data['has_ceiling'] if 'has_ceiling' in data else True # create the Room2D object room = Room2D(data['identifier'], floor_geometry, data['floor_to_ceiling_height'], b_conditions, glz_pars, shd_pars, grnd, top, tolerance) room.has_floor = flr room.has_ceiling = ceil # assign any skylight parameters if they are specified if 'skylight_parameters' in data and data['skylight_parameters'] is not None: try: sky_class = getattr(skypar, data['skylight_parameters']['type']) except AttributeError: raise ValueError( 'Skylight parameter "{}" is not recognized.'.format( data['skylight_parameters']['type'])) room.skylight_parameters = sky_class.from_dict(data['skylight_parameters']) # set all of the other optional properties if 'air_boundaries' in data and data['air_boundaries'] is not None: room.air_boundaries = data['air_boundaries'] if 'display_name' in data and data['display_name'] is not None: room._display_name = data['display_name'] if 'user_data' in data and data['user_data'] is not None: room.user_data = data['user_data'] if data['properties']['type'] == 'Room2DProperties': room.properties._load_extension_attr_from_dict(data['properties']) elif persist_abridged and \ data['properties']['type'] == 'Room2DPropertiesAbridged': room._abridged_properties = data['properties'] return room
[docs] @classmethod def from_honeybee(cls, room, tolerance): """Initialize a Room2D from a Honeybee Room. Note that Dragonfly Room2Ds are abstractions of Honeybee Rooms and there will be loss of information if the Honeybee Room is not an extruded floor plate or if extension properties are assigned to individual Faces or Apertures instead of at the Room level. If the Honeybee Room contains no Floor Faces, None will be returned. Args: room: A Honeybee Room object. tolerance: The maximum difference between values at which point vertices are considered to be the same. """ # first get the floor_geometry for the Room2D using the horizontal boundary try: flr_geo = room.horizontal_boundary(match_walls=True, tolerance=tolerance) except ValueError as e: # not a closed volume; maybe using the floors could work flr_geos = room.horizontal_floor_boundaries( match_walls=True, tolerance=tolerance) if len(flr_geos) == 0: # degenerate room raise ValueError(e) flr_geos = sorted(flr_geos, key=lambda x: x.area, reverse=True) flr_geo = flr_geos[0] # use the geometry with the largest area flr_geo = flr_geo if flr_geo.normal.z >= 0 else flr_geo.flip() # match the segments of the floor geometry to walls of the Room segs = flr_geo.boundary_segments if flr_geo.holes is None else \ flr_geo.boundary_segments + \ tuple(seg for hole in flr_geo.hole_segments for seg in hole) boundary_conditions = [bcs.outdoors] * len(segs) window_parameters = [None] * len(segs) air_bounds = [False] * len(segs) for i, seg in enumerate(segs): wall_f = cls._segment_wall_face(room, seg, tolerance) if wall_f is not None: boundary_conditions[i] = wall_f.boundary_condition if len(wall_f._apertures) != 0 or len(wall_f._doors) != 0: sf_objs = wall_f._apertures + wall_f._doors w_geos = [sf.geometry for sf in sf_objs] is_drs = [isinstance(sf, Door) for sf in sf_objs] if abs(wall_f.normal.z) <= 0.01: # vertical wall window_parameters[i] = DetailedWindows.from_face3ds( w_geos, seg, is_drs) else: # angled wall; scale the Y to covert to vertical w_p = Plane(Vector3D(seg.v.y, -seg.v.x, 0), seg.p, seg.v) w3d = [Face3D([p.project(w_p.n, w_p.o) for p in geo.boundary]) for geo in w_geos] window_parameters[i] = DetailedWindows.from_face3ds( w3d, seg, is_drs) if isinstance(wall_f.type, AirBoundary): air_bounds[i] = True # determine the ceiling height, and top/bottom boundary conditions floor_to_ceiling_height = room.geometry.max.z - room.geometry.min.z is_ground_contact = all([isinstance(f.boundary_condition, Ground) for f in room.faces if isinstance(f.type, Floor)]) is_top_exposed = all([isinstance(f.boundary_condition, Outdoors) for f in room.faces if isinstance(f.type, RoofCeiling)]) has_floor = any([isinstance(f.type, AirBoundary) for f in room.faces if f.altitude < -89.0]) has_ceiling = any([isinstance(f.type, AirBoundary) for f in room.faces if f.altitude > 89.0]) # create the Dragonfly Room2D room_2d = cls( room.identifier, flr_geo, floor_to_ceiling_height, boundary_conditions, window_parameters, None, is_ground_contact, is_top_exposed, tolerance) room_2d.has_floor = has_floor room_2d.has_ceiling = has_ceiling # check if there are any skylights to be added skylights, are_doors = [], [] for f in room.faces: if isinstance(f.type, RoofCeiling): sf_objs = f._apertures + f._doors for sf in sf_objs: verts2d = tuple(Point2D(pt.x, pt.y) for pt in sf.geometry.boundary) skylights.append(Polygon2D(verts2d)) are_doors.append(isinstance(sf, Door)) if len(skylights) != 0: room_2d.skylight_parameters = DetailedSkylights(skylights, are_doors) # add the extra optional attributes final_ab = [] for v, bc in zip(air_bounds, room_2d._boundary_conditions): v_f = v if isinstance(bc, Surface) else False final_ab.append(v_f) room_2d.air_boundaries = final_ab room_2d._display_name = room._display_name room_2d._user_data = None if room.user_data is None else room.user_data.copy() room_2d.properties.from_honeybee(room.properties) return room_2d
[docs] @classmethod def from_polygon(cls, identifier, polygon, floor_height, floor_to_ceiling_height, boundary_conditions=None, window_parameters=None, shading_parameters=None, is_ground_contact=False, is_top_exposed=False): """Create a Room2D from a ladybug-geometry Polygon2D and a floor_height. Note that this method is not recommended for a Room with one or more holes (like a courtyard) since polygons cannot have holes within them. Args: identifier: Text string for a unique Room2D ID. Must be < 100 characters and not contain any spaces or special characters. polygon: A single Polygon2D object representing the floor plate of the Room. floor_height: A float value to place the polygon within 3D space. floor_to_ceiling_height: A number for the height above the floor where the ceiling begins. Typical values range from 3 to 5 meters. boundary_conditions: A list of boundary conditions that match the number of segments in the input floor_geometry. These will be used to assign boundary conditions to each of the walls of the Room in the resulting model. If None, all boundary conditions will be Outdoors or Ground depending on whether ceiling of the room is below 0 (the assumed ground plane). Default: None. window_parameters: A list of WindowParameter objects that dictate how the window geometries will be generated for each of the walls. If None, no windows will exist over the entire Room2D. Default: None. shading_parameters: A list of ShadingParameter objects that dictate how the shade geometries will be generated for each of the walls. If None, no shades will exist over the entire Room2D. Default: None. is_ground_contact: A boolean to note whether this Room2D has its floor in contact with the ground. Default: False. is_top_exposed: A boolean to note whether this Room2D has its ceiling exposed to the outdoors. Default: False. """ # check the input polygon and ensure it's counter-clockwise assert isinstance(polygon, Polygon2D), \ 'Expected ladybug_geometry Polygon2D. Got {}'.format(type(polygon)) if polygon.is_clockwise: polygon = polygon.reverse() if boundary_conditions is not None: boundary_conditions = list(reversed(boundary_conditions)) if window_parameters is not None: new_win_pars = [] for seg, win_par in zip(polygon.segments, reversed(window_parameters)): if isinstance(win_par, _AsymmetricBase): new_win_pars.append(win_par.flip(seg.length)) else: new_win_pars.append(win_par) window_parameters = new_win_pars if shading_parameters is not None: shading_parameters = list(reversed(shading_parameters)) # build the Face3D without using right-hand rule to ensure alignment w/ bcs base_plane = Plane(Vector3D(0, 0, 1), Point3D(0, 0, floor_height)) vert3d = tuple(base_plane.xy_to_xyz(_v) for _v in polygon.vertices) floor_geometry = Face3D(vert3d, base_plane, enforce_right_hand=False) return cls(identifier, floor_geometry, floor_to_ceiling_height, boundary_conditions, window_parameters, shading_parameters, is_ground_contact, is_top_exposed)
[docs] @classmethod def from_vertices(cls, identifier, vertices, floor_height, floor_to_ceiling_height, boundary_conditions=None, window_parameters=None, shading_parameters=None, is_ground_contact=False, is_top_exposed=False): """Create a Room2D from 2D vertices with each vertex as an iterable of 2 floats. Note that this method is not recommended for a Room with one or more holes (like a courtyard) since the distinction between hole vertices and boundary vertices cannot be derived from a single list of vertices. Args: identifier: Text string for a unique Room2D ID. Must be < 100 characters and not contain any spaces or special characters. vertices: A flattened list of 2 or more vertices as (x, y) that trace the outline of the floor plate. floor_height: A float value to place the polygon within 3D space. floor_to_ceiling_height: A number for the height above the floor where the ceiling begins. Typical values range from 3 to 5 meters. boundary_conditions: A list of boundary conditions that match the number of segments in the input floor_geometry. These will be used to assign boundary conditions to each of the walls of the Room in the resulting model. If None, all boundary conditions will be Outdoors or Ground depending on whether ceiling of the room is below 0 (the assumed ground plane). Default: None. window_parameters: A list of WindowParameter objects that dictate how the window geometries will be generated for each of the walls. If None, no windows will exist over the entire Room2D. Default: None. shading_parameters: A list of ShadingParameter objects that dictate how the shade geometries will be generated for each of the walls. If None, no shades will exist over the entire Room2D. Default: None. is_ground_contact: A boolean to note whether this Room2D has its floor in contact with the ground. Default: False. is_top_exposed: A boolean to note whether this Room2D has its ceiling exposed to the outdoors. Default: False. """ polygon = Polygon2D(tuple(Point2D(*v) for v in vertices)) return cls.from_polygon( identifier, polygon, floor_height, floor_to_ceiling_height, boundary_conditions, window_parameters, shading_parameters, is_ground_contact, is_top_exposed)
@property def floor_geometry(self): """A horizontal Face3D object representing the floor plate of the Room.""" return self._floor_geometry @property def floor_to_ceiling_height(self): """Get or set a number for the distance between the floor and the ceiling.""" return self._floor_to_ceiling_height @floor_to_ceiling_height.setter def floor_to_ceiling_height(self, value): self._floor_to_ceiling_height = float_positive(value, 'floor-to-ceiling height') assert self._floor_to_ceiling_height != 0, 'Room2D floor-to-ceiling height ' \ 'cannot be zero.' @property def boundary_conditions(self): """Get or set a tuple of boundary conditions for the wall boundary conditions.""" return tuple(self._boundary_conditions) @boundary_conditions.setter def boundary_conditions(self, value): value = self._check_wall_assigned_object(value, 'boundary conditions') for val, glz in zip(value, self._window_parameters): assert val in bcs, 'Expected BoundaryCondition. Got {}'.format(type(value)) if glz is not None: assert isinstance(val, (Outdoors, Surface)), \ '{} cannot be assigned to a wall with windows.'.format(val) self._boundary_conditions = value @property def window_parameters(self): """Get or set a tuple of WindowParameters describing how to generate windows. """ return tuple(self._window_parameters) @window_parameters.setter def window_parameters(self, value): if value is not None: value = self._check_wall_assigned_object(value, 'window_parameters') for val, bc in zip(value, self._boundary_conditions): if val is not None: assert isinstance(val, _WindowParameterBase), \ 'Expected Window Parameters. Got {}'.format(type(value)) assert isinstance(bc, (Outdoors, Surface)), \ '{} cannot be assigned to a wall with windows.'.format(bc) self._window_parameters = value else: self._window_parameters = [None for i in range(len(self))] @property def shading_parameters(self): """Get or set a tuple of ShadingParameters describing how to generate shades. """ return tuple(self._shading_parameters) @shading_parameters.setter def shading_parameters(self, value): if value is not None: value = self._check_wall_assigned_object(value, 'shading_parameters') for val in value: if val is not None: assert isinstance(val, _ShadingParameterBase), \ 'Expected Shading Parameters. Got {}'.format(type(value)) self._shading_parameters = value else: self._shading_parameters = [None for i in range(len(self))] @property def air_boundaries(self): """Get or set a tuple of booleans for whether each wall has an air boundary type. False values indicate a standard opaque type while True values indicate an AirBoundary type. All walls will be False by default. Note that any walls with a True air boundary must have a Surface boundary condition without any windows. """ if self._air_boundaries is None: self._air_boundaries = [False] * len(self) return tuple(self._air_boundaries) @air_boundaries.setter def air_boundaries(self, value): if value is not None: value = self._check_wall_assigned_object(value, 'air boundaries') value = [bool(val) for val in value] all_props = zip(value, self._boundary_conditions, self._window_parameters) for val, bnd, glz in all_props: if val: assert isinstance(bnd, Surface), 'Air boundaries must be assigned ' \ 'to walls with Surface boundary conditions. Not {}.'.format(bnd) assert glz is None, \ 'Air boundaries cannot be assigned to a wall with windows.' self._air_boundaries = value @property def is_ground_contact(self): """Get or set a boolean noting whether the floor is in contact with the ground. """ return self._is_ground_contact @is_ground_contact.setter def is_ground_contact(self, value): self._is_ground_contact = bool(value) @property def is_top_exposed(self): """Get or set a boolean noting whether the ceiling is exposed to the outdoors. """ return self._is_top_exposed @is_top_exposed.setter def is_top_exposed(self, value): self._is_top_exposed = bool(value) @property def has_floor(self): """Get or set a boolean for whether the room has a Floor or an AirBoundary. If False (for AirBoundary), this property will only be meaningful if the model is translated to Honeybee with ceiling adjacency solved and there is a Room2D below this one with a has_ceiling property set to False. """ return self._has_floor @has_floor.setter def has_floor(self, value): self._has_floor = bool(value) @property def has_ceiling(self): """Get or set a boolean for whether the room has a RoofCeiling or an AirBoundary. If False (for AirBoundary), this property will only be meaningful if the model is translated to Honeybee with ceiling adjacency solved and there is a Room2D above this one with a has_floor property set to False. """ return self._has_ceiling @has_ceiling.setter def has_ceiling(self, value): self._has_ceiling = bool(value) @property def skylight_parameters(self): """Get or set SkylightParameters describing how to generate skylights. """ return self._skylight_parameters @skylight_parameters.setter def skylight_parameters(self, value): if value is not None: assert isinstance(value, _SkylightParameterBase), \ 'Expected Skylight Parameters. Got {}'.format(type(value)) self._skylight_parameters = value @property def parent(self): """Get the parent Story if it is assigned. None if it is not assigned.""" return self._parent @property def has_parent(self): """Get a boolean noting whether this Room2D has a parent Story.""" return self._parent is not None @property def floor_segments(self): """Get a list of LineSegment3D objects for each wall of the Room.""" return self._floor_geometry.boundary_segments if self._floor_geometry.holes is \ None else self._floor_geometry.boundary_segments + \ tuple(seg for hole in self._floor_geometry.hole_segments for seg in hole) @property def floor_segments_2d(self): """Get a list of LineSegment2D objects for each wall of the Room.""" return self._floor_geometry.boundary_polygon2d.segments if \ self._floor_geometry.holes is None else \ self._floor_geometry.boundary_polygon2d.segments + \ tuple(seg for hole in self._floor_geometry.hole_polygon2d for seg in hole.segments) @property def segment_count(self): """Get the number of segments making up the floor geometry. This is equal to the number of walls making up the Room. """ return self._segment_count @property def segment_normals(self): """Get a list of Vector2D objects for the normal of each segment.""" return [Vector2D(seg.v.y, -seg.v.x).normalize() for seg in self.floor_segments] @property def floor_height(self): """Get a number for the height of the floor above the ground.""" return self._floor_geometry[0].z @property def ceiling_height(self): """Get a number for the height of the ceiling above the ground.""" return self.floor_height + self.floor_to_ceiling_height @property def volume(self): """Get a number for the volume of the Room.""" return self.floor_area * self.floor_to_ceiling_height @property def floor_area(self): """Get a number for the floor area of the Room.""" return self._floor_geometry.area @property def exterior_wall_area(self): """Get a the total wall area of the Room with an Outdoors boundary condition. """ wall_areas = [] for seg, bc in zip(self.floor_segments, self._boundary_conditions): if isinstance(bc, Outdoors): wall_areas.append(seg.length * self.floor_to_ceiling_height) return sum(wall_areas) @property def interior_wall_area(self): """Get a the total wall area of the Room without an Outdoors or Ground BC. """ wall_areas = [] for seg, bc in zip(self.floor_segments, self._boundary_conditions): if not isinstance(bc, (Outdoors, Ground)): wall_areas.append(seg.length * self.floor_to_ceiling_height) return sum(wall_areas) @property def exterior_window_area(self): """Get a the total aperture area of the Room with an Outdoors boundary condition. """ glz_areas = [] for seg, bc, glz in zip(self.floor_segments, self._boundary_conditions, self._window_parameters): if isinstance(bc, Outdoors) and glz is not None: area = glz.area_from_segment(seg, self.floor_to_ceiling_height) glz_areas.append(area) return sum(glz_areas) @property def skylight_area(self): """Get a the total aperture area of the Room with an Outdoors boundary condition. """ if self.is_top_exposed and self.skylight_parameters is not None: return self.skylight_parameters.area_from_face(self.floor_geometry) return 0 @property def exterior_aperture_area(self): """Get a the total aperture area of the Room with an Outdoors boundary condition. """ return self.exterior_window_area + self.skylight_area @property def is_core(self): """Get a boolean for whether the Room2D is in the core of a story. Core Room2Ds have no walls exposed to the outdoors. """ return self.exterior_wall_area == 0 @property def is_perimeter(self): """Get a boolean for whether the Room2D is on the perimeter of a story. Perimeter Room2Ds have walls exposed to the outdoors. """ return self.exterior_wall_area != 0 @property def min(self): """Get a Point2D for the min bounding rectangle vertex in the XY plane. This is useful in calculations to determine if this Room2D is in proximity to other Room2Ds. """ return self._floor_geometry.boundary_polygon2d.min @property def max(self): """Get a Point2D for the max bounding rectangle vertex in the XY plane. This is useful in calculations to determine if this Room2D is in proximity to other Room2Ds. """ return self._floor_geometry.boundary_polygon2d.max @property def center(self): """Get a Point2D for the center bounding rectangle vertex in the XY plane. This is useful in calculations to determine if this Room2D is inside other polygons. """ return self._floor_geometry.boundary_polygon2d.center
[docs] def label_point(self, tolerance=0.01): """Get a Point3D to label this Room2D in 3D space. This point will always lie within the polygon formed by the floor_geometry regardless of whether this geometry is concave or has holes. Args: tolerance: The tolerance to which the pole_of_inaccessibility will be computed in the event that the floor_geometry is concave or has holes. Note that this does not need to be equal to the Model tolerance and should usually be larger than the Model tolerance to avoid long calculation times. (Default: 0.01). """ return self.floor_geometry.center if self.floor_geometry.is_convex else \ self.floor_geometry.pole_of_inaccessibility(tolerance)
[docs] def segment_orientations(self, north_vector=Vector2D(0, 1)): """A list of numbers between 0 and 360 for the orientation of the segments. 0 = North, 90 = East, 180 = South, 270 = West Args: north_vector: A ladybug_geometry Vector2D for the north direction. Default is the Y-axis (0, 1). """ normals = (Vector2D(sg.v.y, -sg.v.x) for sg in self.floor_segments) return [math.degrees(north_vector.angle_clockwise(norm)) for norm in normals]
[docs] def segment_indices_by_guide_lines(self, lines, tolerance=0.01): """Get the indices of segments in this Room2D that lie along given guide lines. The resulting indices can be used to set boundary conditions, windows, adjacencies, etc. for segments on this Room2D. Args: lines: A list of LineSegment2D objects to note which segment indices should be returned. tolerance: The minimum difference in coordinate values for them to be considered touching. (Default: 0.01). """ seg_indices = [] for i, seg in enumerate(self.floor_segments_2d): if self._seg_on_guide_lines(seg, lines, tolerance): seg_indices.append(i) return seg_indices
[docs] def overlap_area(self, other_room2d, tolerance=0.01): """Get the area of this Room2D that overlaps with another Room2D. This is useful for helping identify cases where a given Room2D might be an updated version of this Room2D (in the same location within a larger Story) and should therefore replace this Room2D. This method first performs a bounding rectangle check between the Room2Ds to evaluate whether an overlap is possible before computing the percentage, making it efficient to run with large groups of Room2Ds. Args: other_room_2d: Another Room2D object to be checked for overlap with this one. tolerance: The minimum difference in coordinate values that the room vertices must have for them to be considered overlapping. (Default: 0.01). """ # first check whether the bounding rectangles around the geometry overlap self_face, other_face = self.floor_geometry, other_room2d.floor_geometry poly_1, poly_2 = self_face.boundary_polygon2d, other_face.boundary_polygon2d if not Polygon2D.overlapping_bounding_rect(poly_1, poly_2, tolerance): return 0 # no overlap in bounding rect; gap impossible # perform a boolean intersection operation between the two floor Face3Ds self._floor_geometry ang_tol = math.radians(1) new_geos = Face3D.coplanar_intersection( self_face, other_face, tolerance, ang_tol) if new_geos is None or len(new_geos) == 0: return 0 # the Face3Ds did not overlap with one another return sum(f.area for f in new_geos)
[docs] def set_outdoor_window_parameters(self, window_parameter): """Set all of the outdoor walls to have the same window parameters.""" assert isinstance(window_parameter, _WindowParameterBase), \ 'Expected Window Parameters. Got {}'.format(type(window_parameter)) glz_ps = [] for bc in self._boundary_conditions: glz_p = window_parameter if isinstance(bc, Outdoors) else None glz_ps.append(glz_p) self._window_parameters = glz_ps
[docs] def set_outdoor_shading_parameters(self, shading_parameter): """Set all of the outdoor walls to have the same shading parameters.""" assert isinstance(shading_parameter, _ShadingParameterBase), \ 'Expected Window Parameters. Got {}'.format(type(shading_parameter)) shd_ps = [] for bc in self._boundary_conditions: shd_p = shading_parameter if isinstance(bc, Outdoors) else None shd_ps.append(shd_p) self._shading_parameters = shd_ps
[docs] def to_rectangular_windows(self): """Convert all of the windows of the Room2D to the RectangularWindows format.""" glz_ps = [] for seg, glz in zip(self.floor_segments, self._window_parameters): if glz is not None: glz = glz.to_rectangular_windows(seg, self.floor_to_ceiling_height) glz_ps.append(glz) self._window_parameters = glz_ps
[docs] def to_detailed_windows(self): """Convert all of the windows of the Room2D to the DetailedWindows format.""" glz_ps = [] for seg, glz in zip(self.floor_segments, self._window_parameters): if glz is not None and not isinstance(glz, DetailedWindows): glz = glz.to_rectangular_windows(seg, self.floor_to_ceiling_height) glz = glz.to_detailed_windows() glz_ps.append(glz) self._window_parameters = glz_ps
[docs] def add_prefix(self, prefix): """Change the identifier of this object by inserting a prefix. This is particularly useful in workflows where you duplicate and edit a starting object and then want to combine it with the original object into one Model (like making a model of repeated rooms) since all objects within a Model must have unique identifiers. Args: prefix: Text that will be inserted at the start of this object's (and child segments') identifier and display_name. It is recommended that this prefix be short to avoid maxing out the 100 allowable characters for dragonfly identifiers. """ self._identifier = clean_string('{}_{}'.format(prefix, self.identifier)) if self._display_name is not None: self.display_name = '{}_{}'.format(prefix, self.display_name) self.properties.add_prefix(prefix) for i, bc in enumerate(self._boundary_conditions): if isinstance(bc, Surface): new_face_id = '{}_{}'.format(prefix, bc.boundary_condition_objects[0]) new_room_id = '{}_{}'.format(prefix, bc.boundary_condition_objects[1]) self._boundary_conditions[i] = \ Surface((new_face_id, new_room_id))
[docs] def generate_grid(self, x_dim, y_dim=None, offset=1.0): """Get a gridded Mesh3D object offset from the floor of this room. Note that the x_dim and y_dim refer to dimensions within the XY coordinate system of the floor Faces's plane. So rotating the planes of the floor geometry will result in rotated grid cells. Args: x_dim: The x dimension of the grid cells as a number. y_dim: The y dimension of the grid cells as a number. Default is None, which will assume the same cell dimension for y as is set for x. offset: A number for how far to offset the grid from the base face. Default is 1.0, which will not offset the grid to be 1 unit above the floor. """ return self.floor_geometry.mesh_grid(x_dim, y_dim, offset, False)
[docs] def set_adjacency( self, other_room_2d, self_seg_index, other_seg_index, resolve_window_conflicts=True): """Set a segment of this Room2D to be adjacent to another and vice versa. Note that, adjacent segments must possess matching WindowParameters in order to be valid. Args: other_room_2d: Another Room2D object to be set adjacent to this one. self_seg_index: An integer for the wall segment of this Room2D that will be set adjacent to the other_room_2d. other_seg_index:An integer for the wall segment of the other_room_2d that will be set adjacent to this Room2D. resolve_window_conflicts: Boolean to note whether conflicts between window parameters of adjacent segments should be resolved during adjacency setting or an error should be raised about the mismatch. Resolving conflicts will default to the window parameters with the larger are and assign them to the other segment. (Default: True). """ assert isinstance(other_room_2d, Room2D), \ 'Expected dragonfly Room2D. Got {}.'.format(type(other_room_2d)) # set the boundary conditions of the segments ids_1 = ('{}..Face{}'.format(self.identifier, self_seg_index + 1), self.identifier) ids_2 = ('{}..Face{}'.format(other_room_2d.identifier, other_seg_index + 1), other_room_2d.identifier) self._boundary_conditions[self_seg_index] = Surface(ids_2) other_room_2d._boundary_conditions[other_seg_index] = Surface(ids_1) # check that the window parameters match between segments wp1 = self._window_parameters[self_seg_index] wp2 = other_room_2d._window_parameters[other_seg_index] if wp1 is not None or wp2 is not None: if wp1 != wp2 or isinstance(wp1, _AsymmetricBase): if resolve_window_conflicts: ftc1 = self.floor_to_ceiling_height ftc2 = other_room_2d.floor_to_ceiling_height min_ftc = min((ftc1, ftc2)) seg1 = self.floor_segments[self_seg_index] a1 = wp1.area_from_segment(seg1, min_ftc) if wp1 is not None else 0 seg2 = other_room_2d.floor_segments[other_seg_index] a2 = wp2.area_from_segment(seg2, min_ftc) if wp2 is not None else 0 if a1 > a2: other_room_2d._window_parameters[other_seg_index] = \ wp1.flip(seg2.length) if isinstance(wp1, _AsymmetricBase) \ else wp1 else: self._window_parameters[self_seg_index] = wp2.flip(seg1.length) \ if isinstance(wp2, _AsymmetricBase) else wp2 else: if wp1 != wp2: msg = 'Window parameters do not match between adjacent ' \ 'Rooms "{}" and "{}".'.format( self.identifier, other_room_2d.identifier) raise AssertionError(msg)
[docs] def set_boundary_condition(self, seg_index, boundary_condition): """Set a single segment of this Room2D to have a certain boundary condition. Args: seg_index: An integer for the wall segment of this Room2D for which the boundary condition will be set. boundary_condition: A boundary condition object. """ assert boundary_condition in bcs, \ 'Expected boundary condition. Got {}.'.format(type(boundary_condition)) if self._window_parameters[seg_index] is not None: assert isinstance(boundary_condition, (Outdoors, Surface)), '{} cannot be ' \ 'assigned to a wall with windows.'.format(boundary_condition) self._boundary_conditions[seg_index] = boundary_condition
[docs] def set_air_boundary(self, seg_index): """Set a single segment of this Room2D to have an air boundary type. Args: seg_index: An integer for the wall segment of this Room2D for which the boundary condition will be set. """ self.air_boundaries # trigger generation of values if they don't exist assert self._window_parameters[seg_index] is None, \ 'Air boundaries cannot be assigned to a wall with windows.' assert isinstance(self._boundary_conditions[seg_index], Surface), \ 'Air boundaries must be assigned to walls with Surface boundary conditions.' self._air_boundaries[seg_index] = True
[docs] def set_window_parameter(self, seg_index, window_parameter=None): """Set a single segment of this Room2D to have a certain window parameter. Args: seg_index: An integer for the wall segment of this Room2D for which the window parameter will be set. window_parameter: A window parameter object to be assigned to the segment. If None, any existing WindowParameters assigned to the segment will be removed. (Default: None). """ if window_parameter is not None: assert isinstance(window_parameter, _WindowParameterBase), \ 'Expected Window Parameters. Got {}'.format(type(window_parameter)) accept_bc = (Outdoors, Surface) assert isinstance(self._boundary_conditions[seg_index], accept_bc), \ 'Windows cannot be assigned to a wall with {} boundary ' \ 'condition.'.format(self._boundary_conditions[seg_index]) self._window_parameters[seg_index] = window_parameter
[docs] def offset_windows(self, offset_distance, tolerance=0.01): """Offset detailed windows 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. Args: offset_distance: Distance with which the edges of each window 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). """ for wp in self._window_parameters: if isinstance(wp, _AsymmetricBase): wp.offset(offset_distance, tolerance)
[docs] def offset_skylights(self, offset_distance, tolerance=0.01): """Offset detailed skylights 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. Args: offset_distance: Distance with which the edges of each window 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). """ if isinstance(self._skylight_parameters, DetailedSkylights): self._skylight_parameters.offset(offset_distance, tolerance)
[docs] def offset_skylights_from_edges(self, offset_distance=0.05, tolerance=0.01): """Offset detailed skylights so all vertices lie inside the Room2D boundary. Args: offset_distance: Distance from the edge of the room 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). """ if isinstance(self._skylight_parameters, DetailedSkylights): self._skylight_parameters.offset_polygons_for_face( self.floor_geometry, offset_distance, tolerance)
[docs] def reset_adjacency(self): """Set all Surface boundary conditions of this Room2D to be Outdoors.""" for i, bc in enumerate(self._boundary_conditions): if isinstance(bc, Surface): self._boundary_conditions[i] = bcs.outdoors
[docs] def move(self, moving_vec): """Move this Room2D along a vector. Args: moving_vec: A ladybug_geometry Vector3D with the direction and distance to move the room. """ self._floor_geometry = self._floor_geometry.move(moving_vec) if isinstance(self._skylight_parameters, DetailedSkylights): self._skylight_parameters = self._skylight_parameters.move(moving_vec) self.properties.move(moving_vec)
[docs] def rotate_xy(self, angle, origin): """Rotate this Room2D counterclockwise in the XY plane by a certain angle. Args: angle: An angle in degrees. origin: A ladybug_geometry Point3D for the origin around which the object will be rotated. """ self._floor_geometry = self._floor_geometry.rotate_xy( math.radians(angle), origin) if isinstance(self._skylight_parameters, DetailedSkylights): self._skylight_parameters = self._skylight_parameters.rotate(angle, origin) self.properties.rotate_xy(angle, origin)
[docs] def reflect(self, plane): """Reflect this Room2D across a plane. Args: plane: A ladybug_geometry Plane across which the object will be reflected. """ assert plane.n.z == 0, \ 'Plane normal must be in XY plane to use it on Room2D.reflect.' self._floor_geometry = self._floor_geometry.reflect(plane.n, plane.o) if self._floor_geometry.normal.z < 0: # ensure upward-facing Face3D new_bcs, new_win_pars, new_shd_pars = Room2D._flip_wall_assigned_objects( self._floor_geometry, self._boundary_conditions, self._window_parameters, self._shading_parameters) self._boundary_conditions = new_bcs self._window_parameters = new_win_pars self._shading_parameters = new_shd_pars self._floor_geometry = self._floor_geometry.flip() if isinstance(self._skylight_parameters, DetailedSkylights): self._skylight_parameters = self._skylight_parameters.reflect(plane) self.properties.reflect(plane)
[docs] def scale(self, factor, origin=None): """Scale this Room2D by a factor from an origin point. Note that this will scale both the Room2D geometry and the WindowParameters and FacadeParameters assigned to this 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). """ # scale the Room2D geometry self._floor_geometry = self._floor_geometry.scale(factor, origin) self._floor_to_ceiling_height = self._floor_to_ceiling_height * factor # scale the window parameters for i, win_par in enumerate(self._window_parameters): if win_par is not None: self._window_parameters[i] = win_par.scale(factor) # scale the shading parameters for i, shd_par in enumerate(self._shading_parameters): if shd_par is not None: self._shading_parameters[i] = shd_par.scale(factor) # scale the skylight parameters if self._skylight_parameters is not None: self._skylight_parameters = self._skylight_parameters.scale(factor, origin) \ if isinstance(self._skylight_parameters, DetailedSkylights) else \ self._skylight_parameters.scale(factor) self.properties.scale(factor, origin)
[docs] def snap_to_grid(self, grid_increment): """Snap this Room2D's vertices to the nearest grid node defined by an increment. All properties assigned to the Room2D will be preserved and the number of vertices will remain constant. This means that this method can often create duplicate vertices and it might be desirable to run the remove_duplicate_vertices method after running this one. Args: grid_increment: A positive number for dimension of each grid cell. This typically should be equal to the tolerance or larger but should not be larger than the smallest detail of the Room2D that you wish to resolve. """ # loop through the vertices and snap them new_boundary, new_holes = [], None for pt in self._floor_geometry.boundary: new_x = grid_increment * round(pt.x / grid_increment) new_y = grid_increment * round(pt.y / grid_increment) new_boundary.append(Point3D(new_x, new_y, pt.z)) if self._floor_geometry.holes is not None: new_holes = [] for hole in self._floor_geometry.holes: new_hole = [] for pt in hole: new_x = grid_increment * round(pt.x / grid_increment) new_y = grid_increment * round(pt.y / grid_increment) new_hole.append(Point3D(new_x, new_y, pt.z)) new_holes.append(new_hole) # rebuild the new floor geometry and assign it to the Room2D self._floor_geometry = Face3D( new_boundary, self._floor_geometry.plane, new_holes)
[docs] def snap_to_points(self, points, distance): """Snap this Room2D's vertices to a list of points. All properties assigned to this Room2D will be preserved and the number of vertices will remain constant. This means that this method can often create duplicate vertices and it might be desirable to run the remove_duplicate_vertices method after running this one. Args: points: A list of ladybug_geometry Point2Ds to which the Room2D vertices will be snapped if they are near. distance: The maximum distance between a Room2D vertex and the input point where the vertex will be moved to lie on the polyline. Vertices beyond this distance will be left as they are. """ # create a 3D version of the points if len(points) == 0: return vertices = [] for pt in points: if isinstance(pt, Point2D): vertices.append(Point3D(pt.x, pt.y, self.floor_height)) else: msg = 'Expected point2D. Got {}.'.format(type(pt)) raise TypeError(msg) # get lists of vertices for the Room2D.floor_geometry to be edited edit_boundary = self._floor_geometry.boundary edit_holes = self._floor_geometry.holes \ if self._floor_geometry.has_holes else None # perform the snapping operation new_boundary, new_holes = [], None for pt in edit_boundary: dists = [pt.distance_to_point(pt_3d) for pt_3d in vertices] sort_pt = sorted(zip(dists, vertices), key=lambda pair: pair[0]) if sort_pt[0][0] <= distance: new_boundary.append(sort_pt[0][1]) else: new_boundary.append(pt) if edit_holes is not None: new_holes = [] for hole in edit_holes: new_hole = [] for pt in hole: dists = [pt.distance_to_point(pt_3d) for pt_3d in vertices] sort_pt = sorted(zip(dists, vertices), key=lambda pair: pair[0]) if sort_pt[0][0] <= distance: new_hole.append(sort_pt[0][1]) else: new_hole.append(pt) new_holes.append(new_hole) # rebuild the new floor geometry and assign it to the Room2D self._floor_geometry = Face3D( new_boundary, self._floor_geometry.plane, new_holes)
[docs] def snap_to_line_end_points(self, line, distance): """Snap this Room2D's vertices to the endpoints of a line segment. All properties assigned to this Room2D will be preserved and the number of vertices will remain constant. This means that this method can often create duplicate vertices and it might be desirable to run the remove_duplicate_vertices method after running this one. Args: line: A ladybug_geometry LineSegment2D to which the Room2D vertices will be snapped if they are near the end points. distance: The maximum distance between a Room2D vertex and the polyline where the vertex will be moved to lie on the polyline. Vertices beyond this distance will be left as they are. """ # create a 3D version of the line segment if isinstance(line, LineSegment2D): line_ray_3d = LineSegment3D( Point3D(line.p.x, line.p.y, self.floor_height), Vector3D(line.v.x, line.v.y, 0) ) else: msg = 'Expected LineSegment2D. Got {}.'.format(type(line)) raise TypeError(msg) # get lists of vertices for the Room2D.floor_geometry to be edited edit_boundary = self._floor_geometry.boundary edit_holes = self._floor_geometry.holes \ if self._floor_geometry.has_holes else None # perform the snapping operation vertices = line_ray_3d.endpoints new_boundary, new_holes = [], None for pt in edit_boundary: dists = [pt.distance_to_point(pt_3d) for pt_3d in vertices] sort_pt = sorted(zip(dists, vertices), key=lambda pair: pair[0]) if sort_pt[0][0] <= distance: new_boundary.append(sort_pt[0][1]) else: new_boundary.append(pt) if edit_holes is not None: new_holes = [] for hole in edit_holes: new_hole = [] for pt in hole: dists = [pt.distance_to_point(pt_3d) for pt_3d in vertices] sort_pt = sorted(zip(dists, vertices), key=lambda pair: pair[0]) if sort_pt[0][0] <= distance: new_hole.append(sort_pt[0][1]) else: new_hole.append(pt) new_holes.append(new_hole) # rebuild the new floor geometry and assign it to the Room2D self._floor_geometry = Face3D( new_boundary, self._floor_geometry.plane, new_holes)
[docs] def align(self, line_ray, distance): """Move any Room2D vertices within a given distance of a line to be on that line. This is useful to clean up cases where wall segments have a lot of zig zags in them. All properties assigned to the Room2D will be preserved and the number of vertices will remain constant. This means that this method can often create duplicate vertices and it might be desirable to run the remove_duplicate_vertices method after running this one. Args: line_ray: A ladybug_geometry Ray2D or LineSegment2D to which the Room2D vertices will be aligned. Ray2Ds will be interpreted as being infinite in both directions while LineSegment2Ds will be interpreted as only existing between two points. distance: The maximum distance between a vertex and the line_ray where the vertex will be moved to lie on the line_ray. Vertices beyond this distance will be left as they are. """ # create a 3D version of the line_ray for the closest point calculation if isinstance(line_ray, Ray2D): line_ray_3d = Ray3D( Point3D(line_ray.p.x, line_ray.p.y, self.floor_height), Vector3D(line_ray.v.x, line_ray.v.y, 0) ) closest_func = closest_point3d_on_line3d_infinite elif isinstance(line_ray, LineSegment2D): line_ray_3d = LineSegment3D( Point3D(line_ray.p.x, line_ray.p.y, self.floor_height), Vector3D(line_ray.v.x, line_ray.v.y, 0) ) closest_func = closest_point3d_on_line3d else: msg = 'Expected Ray2D or LineSegment2D. Got {}.'.format(type(line_ray)) raise TypeError(msg) # loop through the vertices and align them new_boundary, new_holes = [], None for pt in self._floor_geometry.boundary: close_pt = closest_func(pt, line_ray_3d) if pt.distance_to_point(close_pt) <= distance: new_boundary.append(close_pt) else: new_boundary.append(pt) if self._floor_geometry.holes is not None: new_holes = [] for hole in self._floor_geometry.holes: new_hole = [] for pt in hole: close_pt = closest_func(pt, line_ray_3d) if pt.distance_to_point(close_pt) <= distance: new_hole.append(close_pt) else: new_hole.append(pt) new_holes.append(new_hole) # rebuild the new floor geometry and assign it to the Room2D self._floor_geometry = Face3D( new_boundary, self._floor_geometry.plane, new_holes)
[docs] def pull_to_segments(self, line_segments, distance, snap_vertices=True, constrain_edges=False, tolerance=0.01): """Pull this Room2D's vertices to several LineSegment2D. This includes both an alignment to the line segments as well as an optional snapping to the line end points. All properties assigned to this Room2D will be preserved. The benefit of calling this method as opposed to iterating over the segments and calling align (and snap_to_line_end_points) is that this method will only align (and snap) to the closest segment across all of the input line_segments. This often helps avoid snapping to undesirable line segments, particularly when there are two ore more segments that are within the distance. Args: line_segments: A list of ladybug_geometry LineSegment2D to which this Room2D's vertices will be pulled. distance: The maximum distance between a Room2D vertex and the line_segments where the vertex will be moved to lie on the segments. Vertices beyond this distance will be left as they are. snap_vertices: A boolean to note whether Room2D vertices that are close to the segment end points within the distance should be snapped to the end point instead of simply being aligned to the nearest segment. (Default: True). constrain_edges: A boolean to note whether all axes of the edges that were not pulled to the Room2D should be preserved. This is accomplished by evaluating the changed vertices after all pulling operations are performed and identifying stretches of vertices that changed. For each stretch of changed vertices, the start and end points of this stretch will be moved to the intersection between the new pulled room segment and the adjacent original room segment whose axis is to be preserved. (Default: False). tolerance: The minimum difference between the coordinate values at which they are considered co-located. (Default: 0.01, suitable for objects in meters). """ # create a 3D version of the relevant line segments lines_3d = [] for line in line_segments: if isinstance(line, LineSegment2D): line_3d = LineSegment3D( Point3D(line.p.x, line.p.y, self.floor_height), Vector3D(line.v.x, line.v.y, 0) ) lines_3d.append(line_3d) else: msg = 'Expected LineSegment2D. Got {}.'.format(type(line)) raise TypeError(msg) if len(lines_3d) == 0: return # get lists of vertices for the Room2D.floor_geometry to be edited edit_boundary = self._floor_geometry.boundary edit_holes = self._floor_geometry.holes \ if self._floor_geometry.has_holes else None # loop through the Room2D vertices and align them to the segments new_boundary = [] for pt in edit_boundary: dists, c_pts = [], [] for line_ray_3d in lines_3d: close_pt = closest_point3d_on_line3d(pt, line_ray_3d) 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_boundary.append(sort_pt[0][1]) else: new_boundary.append(pt) edit_boundary = new_boundary if edit_holes is not None: new_holes = [] for hole in edit_holes: new_hole = [] for pt in hole: dists, c_pts = [], [] for line_ray_3d in lines_3d: close_pt = closest_point3d_on_line3d(pt, line_ray_3d) 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_hole.append(sort_pt[0][1]) else: new_hole.append(pt) new_holes.append(new_hole) edit_holes = new_holes # if snap_vertices was requested, perform an additional operation to snap them if snap_vertices: vertices = [] for line in lines_3d: vertices.append(line.p1) vertices.append(line.p2) new_boundary = [] for pt in edit_boundary: dists = [pt.distance_to_point(pt_3d) for pt_3d in vertices] sort_pt = sorted(zip(dists, vertices), key=lambda pair: pair[0]) if sort_pt[0][0] <= distance: new_boundary.append(sort_pt[0][1]) else: new_boundary.append(pt) edit_boundary = new_boundary if edit_holes is not None: new_holes = [] for hole in edit_holes: new_hole = [] for pt in hole: dists = [pt.distance_to_point(pt_3d) for pt_3d in vertices] sort_pt = sorted(zip(dists, vertices), key=lambda pair: pair[0]) if sort_pt[0][0] <= distance: new_hole.append(sort_pt[0][1]) else: new_hole.append(pt) new_holes.append(new_hole) edit_holes = new_holes # rebuild the new floor geometry and assign it to the Room2D f_geo = self._floor_geometry self._floor_geometry = Face3D(edit_boundary, f_geo.plane, edit_holes) # if constrain_edges is true, move the end points of each stretch if constrain_edges: self._constrain_edges(f_geo, line_segments, tolerance)
[docs] def pull_to_polyline(self, polyline, distance, snap_vertices=True, constrain_edges=False, tolerance=0.01): """Pull this Room2D's vertices to a Polyline2D. This includes both an alignment to the polyline's segments as well as an optional snapping to the polyline's vertices. All properties assigned to this Room2D will be preserved. Note that this method can often create duplicate vertices and degenerate geometry. So it might be desirable to run the remove_colinear_vertices or the remove_degenerate_holes method after running this one. Args: polyline: A ladybug_geometry Polyline2D to which this Room2D's vertices will be pulled. distance: The maximum distance between a Room2D vertex and the polyline where the vertex will be moved to lie on the polyline. Vertices beyond this distance will be left as they are. snap_vertices: A boolean to note whether Room2D vertices that are close to the polyline vertices within the distance should be snapped to the polyline vertex instead of simply being aligned to the nearest polyline segment. (Default: True). constrain_edges: A boolean to note whether all axes of the edges that were not pulled to the Room2D should be preserved. This is accomplished by evaluating the changed vertices after all pulling operations are performed and identifying stretches of vertices that changed. For each stretch of changed vertices, the start and end points of this stretch will be moved to the intersection between the new pulled room segment and the adjacent original room segment whose axis is to be preserved. (Default: False). tolerance: The minimum difference between the coordinate values at which they are considered co-located. (Default: 0.01, suitable for objects in meters). """ # create LineSegment3Ds from the polyline line_segs = [] for seg in polyline.segments: pt_3d = Point3D(seg.p.x, seg.p.y, self.floor_height) line_ray_3d = LineSegment3D(pt_3d, Vector3D(seg.v.x, seg.v.y, 0)) line_segs.append(line_ray_3d) line_segs.append(line_segs[0].flip()) # ensure last vertex is counted # pull this Room2D to the segments self._pull_to_poly_segments(line_segs, distance, snap_vertices, constrain_edges, tolerance)
[docs] def pull_to_polygon(self, polygon, distance, snap_vertices=True, constrain_edges=False, tolerance=0.01): """Pull this Room2D's vertices to a Polygon2D. This includes both an alignment to the polygon's segments as well as an optional snapping to the polygon's vertices. All properties assigned to this Room2D will be preserved. Note that this method can often create duplicate vertices and degenerate geometry. So it might be desirable to run the remove_colinear_vertices or the remove_degenerate_holes method after running this one. Args: polygon: A ladybug_geometry Polygon2D to which this Room2D's vertices will be pulled. distance: The maximum distance between a Room2D vertex and the polygon where the vertex will be moved to lie on the polygon. Vertices beyond this distance will be left as they are. snap_vertices: A boolean to note whether Room2D vertices that are close to the polygon vertices within the distance should be snapped to the polygon vertex instead of simply being aligned to the nearest polygon segment. (Default: True). constrain_edges: A boolean to note whether all axes of the edges that were not pulled to the Room2D should be preserved. This is accomplished by evaluating the changed vertices after all pulling operations are performed and identifying stretches of vertices that changed. For each stretch of changed vertices, the start and end points of this stretch will be moved to the intersection between the new pulled room segment and the adjacent original room segment whose axis is to be preserved. (Default: False). tolerance: The minimum difference between the coordinate values at which they are considered co-located. (Default: 0.01, suitable for objects in meters). """ # create LineSegment3Ds from the polygon line_segs = [] for seg in polygon.segments: pt_3d = Point3D(seg.p.x, seg.p.y, self.floor_height) line_ray_3d = LineSegment3D(pt_3d, Vector3D(seg.v.x, seg.v.y, 0)) line_segs.append(line_ray_3d) # pull this Room2D to the segments self._pull_to_poly_segments(line_segs, distance, snap_vertices, constrain_edges, tolerance)
[docs] def pull_to_room_2d(self, room_2d, distance, coordinate_vertices=True, constrain_edges=False, tolerance=0.01): """Pull this Room2D's vertices to another Room2D. This includes both an alignment to the other Room2D's segments as well as an optional snapping to the Room2D's vertices. Furthermore, if coordinate_vertices is True, any vertices of the neighboring input room_2d that are within the specified distance but cannot be matched to a vertex on this Room2D within the tolerance will be inserted into this Room2D, splitting the wall segment in the process. All properties assigned to this Room2D will be preserved. Note that this method can often create duplicate vertices and degenerate geometry. So it might be desirable to run the remove_colinear_vertices or the remove_degenerate_holes method after running this one. Args: room_2d: A Room2D to which this Room2D's vertices will be pulled. distance: The maximum distance between a Room2D vertex and the other Room2D where the vertex will be moved to lie on the other Room2D. Vertices beyond this distance will be left as they are. coordinate_vertices: A boolean to note whether Room2D vertices that are close to the other Room2D vertices within the distance should be snapped to the Room2D vertex instead of simply being aligned to the nearest Room2D segment. Additionally, any vertices of the neighboring room_2d that are within the specified distance but cannot be matched to a vertex on this Room2D within the tolerance will be inserted into this Room2D, splitting the wall segment in the process. (Default: True). constrain_edges: A boolean to note whether all axes of the edges that were not pulled to the Room2D should be preserved. This is accomplished by evaluating the changed vertices after all pulling operations are performed and identifying stretches of vertices that changed. For each stretch of changed vertices, the start and end points of this stretch will be moved to the intersection between the new pulled room segment and the adjacent original room segment whose axis is to be preserved. (Default: False). tolerance: The minimum difference between the coordinate values at which they are considered co-located. (Default: 0.01, suitable for objects in meters). """ # convert the other Room2D to a list of polygons original_geo = self.floor_geometry f_geo = room_2d.floor_geometry other_room_polys = [Polygon2D([Point2D(pt.x, pt.y) for pt in f_geo.boundary])] if f_geo.has_holes: for hole in f_geo.holes: h_poly = Polygon2D([Point2D(pt.x, pt.y) for pt in hole]) other_room_polys.append(h_poly) # pull this Room2D to each of the polygons for o_poly in other_room_polys: self.pull_to_polygon(o_poly, distance, coordinate_vertices) # if coordinate_vertices is True, insert extra vertices if coordinate_vertices: self.coordinate_room_2d_vertices(room_2d, distance, tolerance) # if constrain_edges is true, move the end points of each stretch if constrain_edges: pull_segments = [s for poly in other_room_polys for s in poly.segments] self._constrain_edges(original_geo, pull_segments, tolerance)
def _pull_to_poly_segments(self, line_segments, distance, snap_vertices=True, constrain_edges=False, tolerance=0.01): """Pull this Room2D's vertices to LineSegment3D originating from a poly-line/gon. Args: line_segments: A list of ladybug_geometry LineSegment3D with Z-values at this Room2D's floor_height to which this Room2D's vertices will be pulled. distance: The maximum distance between a Room2D vertex and the line_segments where the vertex will be moved to lie on the segments. Vertices beyond this distance will be left as they are. snap_vertices: A boolean to note whether Room2D vertices that are close to the segment end points within the distance should be snapped to the end point instead of simply being aligned to the nearest segment. (Default: True). constrain_edges: A boolean to note whether all axes of the edges that were not pulled to the Room2D should be preserved. This is accomplished by evaluating the changed vertices after all pulling operations are performed and identifying stretches of vertices that changed. For each stretch of changed vertices, the start and end points of this stretch will be moved to the intersection between the new pulled room segment and the adjacent original room segment whose axis is to be preserved. (Default: False). tolerance: The minimum difference between the coordinate values at which they are considered co-located. (Default: 0.01, suitable for objects in meters). """ # first make sure that there are line segments to be pulled to if len(line_segments) == 0: return # get lists of vertices for the Room2D.floor_geometry to be edited edit_boundary = self._floor_geometry.boundary edit_holes = self._floor_geometry.holes \ if self._floor_geometry.has_holes else None # loop through the Room2D vertices and align them to the segments new_boundary = [] for pt in edit_boundary: dists, c_pts = [], [] for line_ray_3d in line_segments: close_pt = closest_point3d_on_line3d(pt, line_ray_3d) 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_boundary.append(sort_pt[0][1]) else: new_boundary.append(pt) edit_boundary = new_boundary if edit_holes is not None: new_holes = [] for hole in edit_holes: new_hole = [] for pt in hole: dists, c_pts = [], [] for line_ray_3d in line_segments: close_pt = closest_point3d_on_line3d(pt, line_ray_3d) 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_hole.append(sort_pt[0][1]) else: new_hole.append(pt) new_holes.append(new_hole) edit_holes = new_holes # if snap_vertices was requested, perform an additional operation to snap them if snap_vertices: vertices = [line.p for line in line_segments] new_boundary = [] for pt in edit_boundary: dists = [pt.distance_to_point(pt_3d) for pt_3d in vertices] sort_pt = sorted(zip(dists, vertices), key=lambda pair: pair[0]) if sort_pt[0][0] <= distance: new_boundary.append(sort_pt[0][1]) else: new_boundary.append(pt) edit_boundary = new_boundary if edit_holes is not None: new_holes = [] for hole in edit_holes: new_hole = [] for pt in hole: dists = [pt.distance_to_point(pt_3d) for pt_3d in vertices] sort_pt = sorted(zip(dists, vertices), key=lambda pair: pair[0]) if sort_pt[0][0] <= distance: new_hole.append(sort_pt[0][1]) else: new_hole.append(pt) new_holes.append(new_hole) edit_holes = new_holes # rebuild the new floor geometry and assign it to the Room2D f_geo = self._floor_geometry self._floor_geometry = Face3D(edit_boundary, f_geo.plane, edit_holes) # if constrain_edges is true, move the end points of each stretch if constrain_edges: segs_2d = [LineSegment2D.from_array(((s.p1.x, s.p1.y), (s.p2.x, s.p2.y))) for s in line_segments] self._constrain_edges(f_geo, segs_2d, tolerance) def _constrain_edges(self, original_floor_geo, pull_segments, tolerance): """Move vertices of this Room2D to preserve original edges.""" # get all of the vertices and segments needed for the operation new_verts = self._floor_geometry.boundary new_segs = self._floor_geometry.boundary_polygon2d.segments old_segs = original_floor_geo.boundary_polygon2d.segments # loop through the vertices and figure out which ones are along the pull_segments pts_moved, any_moved = [], False for seg in new_segs: for o_seg in pull_segments: close_pt = closest_point2d_on_line2d(seg.p1, o_seg) if seg.p1.distance_to_point(close_pt) <= tolerance: pts_moved.append(True) any_moved = True break else: pts_moved.append(False) if not any_moved: return # set a maximum distance for which constrained points can move o_geo = original_floor_geo max_dist = max((o_geo.max.x - o_geo.min.x, o_geo.max.y - o_geo.min.y)) max_d = max_dist * 10 # identify the start and end points of each stretch and move them edit_boundary = [] last_vert_i = len(new_verts) - 1 for i, (pt, moved) in enumerate(zip(new_verts, pts_moved)): if moved: prev_i = i - 1 next_i = i + 1 if i != last_vert_i else 0 if pts_moved[prev_i] and pts_moved[next_i]: # middle of a stretch edit_boundary.append(pt) elif not pts_moved[prev_i] and not pts_moved[next_i]: # lone moved point edit_boundary.append(pt) elif pts_moved[prev_i]: # the end of a stretch prev_seg, next_new_seg = new_segs[prev_i], new_segs[i] for o_seg in old_segs: if o_seg.p2.is_equivalent(next_new_seg.p2, tolerance): next_seg = o_seg break else: # failed to find the original segment edit_boundary.append(pt) continue ray_1 = Ray2D(prev_seg.p1, prev_seg.v) ray_2 = Ray2D(next_seg.p2, -next_seg.v) int_pt = ray_1.intersect_line_ray(ray_2) if int_pt is None or int_pt.distance_to_point(next_seg.p1) > max_d: edit_boundary.append(pt) else: edit_boundary.append(Point3D(int_pt.x, int_pt.y, pt.z)) else: # the beginning of a stretch prev_new_seg, next_seg = new_segs[prev_i], new_segs[i] for o_seg in old_segs: if o_seg.p1.is_equivalent(prev_new_seg.p1, tolerance): prev_seg = o_seg break else: # failed to find the original segment edit_boundary.append(pt) continue ray_1 = Ray2D(prev_seg.p1, prev_seg.v) ray_2 = Ray2D(next_seg.p2, -next_seg.v) int_pt = ray_1.intersect_line_ray(ray_2) if int_pt is None or int_pt.distance_to_point(next_seg.p1) > max_d: edit_boundary.append(pt) else: edit_boundary.append(Point3D(int_pt.x, int_pt.y, pt.z)) else: edit_boundary.append(pt) # rebuild the floor_geometry of this room and add back any holes self._floor_geometry = Face3D( edit_boundary, self._floor_geometry.plane, self._floor_geometry.holes)
[docs] def coordinate_room_2d_vertices(self, room_2d, distance, tolerance=0.01): """Insert vertices to this Room2D to coordinate this Room2D with another Room2D. This is sometimes a useful operation to run after using the pull_to_room_2d method in order to address the case that the Room2D to which this one was pulled has more vertices along the adjacency boundary than this Room2D. In this case, the adjacency between the two Room2Ds will not be clean and extra vertices must be inserted into this Room2D so that geometry matches along the room adjacency. Any vertices of the neighboring input room_2d that are within the specified distance but cannot be matched to a vertex on this Room2D within the tolerance will be inserted into this Room2D, splitting the wall segment in the process. Args: room_2d: A Room2D with which the vertices of this Room2D will be coordinated. distance: The maximum distance between a Room2D vertex and the other Room2D where the vertex will be moved to lie on the other Room2D. Vertices beyond this distance will be left as they are. tolerance: The minimum difference between the coordinate values at which they are considered co-located. (Default: 0.01, suitable for objects in meters). """ # determine all of the vertices of the other Room2D that should be inserted self_segs = list(self.floor_segments_2d) self_pts_2d = [seg.p for seg in self_segs] other_pts_2d = [seg.p for seg in room_2d.floor_segments_2d] insert_pts = [] for o_pt in other_pts_2d: possible_insert = False for i, seg in enumerate(self_segs): if seg.distance_to_point(o_pt) < distance: possible_insert = True break if possible_insert: for s_pt in self_pts_2d: if s_pt.distance_to_point(o_pt) <= tolerance: break else: insert_pts.append((i, o_pt)) # loop through the segments and split them if insertion points were found if len(insert_pts) == 0: return sort_int_pts = sorted(insert_pts, key=lambda x: x[0], reverse=True) edit_code = ['K'] * len(self_segs) for ins_ind, pt in sort_int_pts: split_seg = self_segs[ins_ind] new_seg1 = LineSegment2D.from_end_points(split_seg.p1, pt) new_seg2 = LineSegment2D.from_end_points(pt, split_seg.p2) self_segs[ins_ind] = new_seg2 self_segs.insert(ins_ind, new_seg1) edit_code.insert(ins_ind, 'A') # create a new floor_geometry Face3D and update the geometry with the edit code z_val = self.floor_geometry.boundary[0].z if not self.floor_geometry.has_holes: pts = [Point3D(seg.p.x, seg.p.y, z_val) for seg in self_segs] new_geo = Face3D(pts, self.floor_geometry.plane) else: joined_segs = Polyline2D.join_segments(self_segs, tolerance) new_loops = [] for p_line in joined_segs: pts = [Point3D(pt.x, pt.y, z_val) for pt in p_line.vertices[:-1]] new_loops.append(pts) new_geo = Face3D(new_loops[0], self.floor_geometry.plane, new_loops[1:]) self.update_floor_geometry(new_geo, edit_code, tolerance)
[docs] def remove_duplicate_vertices(self, tolerance=0.01): """Remove duplicate vertices from this Room2D. All properties assigned to the Room2D will be preserved. Args: tolerance: The minimum distance between a vertex and the line it lies upon at which point the vertex is considered colinear. (Default: 0.01, suitable for objects in meters). Returns: A list of integers for the indices of segments that have been removed. """ # loop through the vertices and remove any duplicates exist_abs = self.air_boundaries new_bound, new_bcs, new_win, new_shd, new_abs = [], [], [], [], [] b_pts = self.floor_geometry.boundary b_pts = b_pts[1:] + (b_pts[0],) removed_indices = [] for i, vert in enumerate(b_pts): if not vert.is_equivalent(b_pts[i - 1], tolerance): new_bound.append(b_pts[i - 1]) new_bcs.append(self._boundary_conditions[i]) new_win.append(self._window_parameters[i]) new_shd.append(self._shading_parameters[i]) new_abs.append(exist_abs[i]) else: removed_indices.append(i) new_holes = None if self.floor_geometry.has_holes: new_holes, seg_count = [], len(b_pts) for hole in self.floor_geometry.holes: new_h_pts = [] h_pts = hole[1:] + (hole[0],) for i, vert in enumerate(h_pts): if not vert.is_equivalent(h_pts[i - 1], tolerance): new_h_pts.append(h_pts[i - 1]) new_bcs.append(self._boundary_conditions[seg_count + i]) new_win.append(self._window_parameters[seg_count + i]) new_shd.append(self._shading_parameters[seg_count + i]) new_abs.append(exist_abs[i]) else: removed_indices.append(i) new_holes.append(new_h_pts) seg_count += len(h_pts) # assign the geometry and properties try: self._floor_geometry = Face3D( new_bound, self.floor_geometry.plane, new_holes) except AssertionError as e: # usually a sliver face of some kind raise ValueError( 'Room2D "{}" is degenerate with dimensions less than the ' 'tolerance.\n{}'.format(self.display_name, e)) self._segment_count = len(new_bcs) self._boundary_conditions = new_bcs self._window_parameters = new_win self._shading_parameters = new_shd self._air_boundaries = new_abs return removed_indices
[docs] def remove_degenerate_holes(self, tolerance=0.01): """Remove any holes in this Room2D with an area that evaluates to zero. All properties assigned to the Room2D will be preserved. Args: tolerance: The minimum difference between the coordinate values at which they are considered co-located. (Default: 0.01, suitable for objects in meters). """ if self.floor_geometry.has_holes: # first identify any zero-area holes holes_to_remove = [] for i, hole in enumerate(self.floor_geometry.holes): tf = Face3D(hole, self.floor_geometry.plane) max_dim = max((tf.max.x - tf.min.x, tf.max.y - tf.min.y)) if tf.area < max_dim * tolerance: holes_to_remove.append(i) # if zero-area holes were found, rebuild the Room2D if len(holes_to_remove) > 0: self._remove_holes(holes_to_remove)
[docs] def remove_small_holes(self, area_threshold): """Remove any holes in this Room2D that are below a certain area threshold. All properties assigned to the Room2D will be preserved. Args: area_threshold: A number for the area below which holes will be removed. """ if self.floor_geometry.has_holes: # first identify any holes to remove holes_to_remove = [] for i, hole in enumerate(self.floor_geometry.holes): tf = Face3D(hole, self.floor_geometry.plane) if tf.area < area_threshold: holes_to_remove.append(i) # if removable holes were found, rebuild the Room2D if len(holes_to_remove) > 0: self._remove_holes(holes_to_remove)
def _remove_holes(self, holes_to_remove): """Remove holes in the Room2D given the indices of the holes. Args: holes_to_remove: A list of integers for the indices of holes to be removed. """ # first collect the properties of the boundary exist_abs = self.air_boundaries new_bcs, new_win, new_shd, new_abs = [], [], [], [] seg_count = len(self.floor_geometry.boundary) for i in range(seg_count): new_bcs.append(self._boundary_conditions[i]) new_win.append(self._window_parameters[i]) new_shd.append(self._shading_parameters[i]) new_abs.append(exist_abs[i]) # collect the properties of the new holes new_holes = [] for hi, hole in enumerate(self.floor_geometry.holes): if hi not in holes_to_remove: for i, vert in enumerate(hole): new_bcs.append(self._boundary_conditions[seg_count + i]) new_win.append(self._window_parameters[seg_count + i]) new_shd.append(self._shading_parameters[seg_count + i]) new_abs.append(exist_abs[i]) new_holes.append(hole) seg_count += len(hole) # reset the properties of the Room2D self._floor_geometry = Face3D( self.floor_geometry.boundary, self.floor_geometry.plane, new_holes) self._segment_count = len(new_bcs) self._boundary_conditions = new_bcs self._window_parameters = new_win self._shading_parameters = new_shd self._air_boundaries = new_abs
[docs] def update_floor_geometry(self, new_floor_geometry, edit_code, tolerance=0.01): """Change the floor_geometry of the Room2D with segment-altering specifications. This method is intended to be used when the floor geometry has been edited by some external means and this Room2D should be updated for coordination. The method tries to infer whether an removed floor segment means that an original segment has been merged into another or removed completely using the colinearity of the original segments. A removed segment that is colinear with its neighbor will be merged into it while a removed segment that was not colinear will simply be deleted. Similarly, the method will infer if an added segment indicates a split in an original segment using colinearity. When the result in the new_floor_geometry is two colinear segments, properties of the original segment will be split across the new segments. Otherwise the new segment will receive default properties. Args: new_floor_geometry: A Face3D for the new floor_geometry of this Room2D. Note that this method expects the plane of this Face3D to match the original floor_geometry Face3D and for the counter-clockwise vertex ordering of the segments to be the same as the original floor geometry (though segments can obviously be added or removed). edit_code: A text string that indicates the operations that were performed on the original floor_geometry segments to yield the new_floor_geometry. The following letters are used in this code to indicate the following: * K = a segment that has been kept (possibly moved but not removed) * X = a segment that has been removed * A = a segment that has been added For example, KXKAKKA means that the first segment was kept, the next removed, the next kept, the next added, followed by two kept segments and ending in an added segment. tolerance: The minimum difference between the coordinate values at which they are considered co-located, used to determine colinearity. Default: 0.01, suitable for objects in meters. """ # process the new floor geometry so that it abides by Room2D rules if new_floor_geometry.normal.z <= 0: # ensure upward-facing Face3D new_floor_geometry = new_floor_geometry.flip() o_pl = Plane(Vector3D(0, 0, 1), Point3D(0, 0, new_floor_geometry.plane.o.z)) new_floor_geometry = Face3D(new_floor_geometry.boundary, o_pl, new_floor_geometry.holes) # get the original and the new floor segments orig_segs = self.floor_segments new_segs = new_floor_geometry.boundary_segments if new_floor_geometry.holes is \ None else new_floor_geometry.boundary_segments + \ tuple(seg for hole in new_floor_geometry.hole_segments for seg in hole) # figure out the new properties based on the edit code new_bcs, new_win, new_shd = [], [], [] last_o_seg = orig_segs[-1] orig_i, new_i = 0, 0 for edit_val in edit_code: if edit_val == 'K': new_bcs.append(self._boundary_conditions[orig_i]) new_win.append(self._window_parameters[orig_i]) new_shd.append(self._shading_parameters[orig_i]) last_o_seg = orig_segs[orig_i] orig_i += 1 new_i += 1 elif edit_val == 'X': # determine if the removed segment is colinear del_seg = orig_segs[orig_i] full_line = LineSegment3D.from_end_points(last_o_seg.p1, del_seg.p2) if full_line.distance_to_point(del_seg.p1) <= tolerance: # colinear! if len(new_bcs) != 0: # TODO: figure out a strategy to merge first to end of the list new_bcs[-1] = bcs.outdoors new_win[-1] = DetailedWindows.merge( (new_win[-1], self._window_parameters[orig_i]), (last_o_seg, del_seg), self.floor_to_ceiling_height) last_o_seg = full_line orig_i += 1 elif edit_val == 'A': # determine if the added segment is colinear and within the original add_seg = new_segs[new_i] if last_o_seg.distance_to_point(add_seg.p1) <= tolerance and \ last_o_seg.distance_to_point(add_seg.p2) <= tolerance: # colinear! orig_i = -1 if orig_i >= len(self._boundary_conditions) - 1 \ else orig_i new_bcs.append(self._boundary_conditions[orig_i]) if len(new_win) != 0 and new_win[-1] is not None: # TODO: figure out a strategy to split the end of the list p_lin = LineSegment3D.from_end_points(last_o_seg.p1, add_seg.p1) a_lin = LineSegment3D.from_end_points(add_seg.p1, last_o_seg.p2) w_to_spl = new_win.pop(-1) new_win.extend(w_to_spl.split((p_lin, a_lin), tolerance)) last_o_seg = a_lin else: new_win.append(None) new_shd.append(self._shading_parameters[orig_i]) else: # not colinear; use default properties new_bcs.append(bcs.outdoors) new_win.append(None) new_shd.append(None) new_i += 1 # assign the updated properties to this Room2D self._floor_geometry = new_floor_geometry self._segment_count = len(new_segs) assert self._segment_count == len(new_bcs), 'The operations in the edit_code ' \ 'denote a geometry with {} segments but the new_floor_geometry has {} ' \ 'segments.'.format(len(new_bcs), self._segment_count) self._boundary_conditions = new_bcs self._window_parameters = new_win self._shading_parameters = new_shd self._air_boundaries = None # reset to avoid any conflicts
[docs] def remove_colinear_vertices(self, tolerance=0.01, preserve_wall_props=True): """Get a version of this Room2D without colinear or duplicate vertices. Args: tolerance: The minimum distance between a vertex and the line it lies upon at which point the vertex is considered colinear. Default: 0.01, suitable for objects in meters. preserve_wall_props: Boolean to note whether existing window parameters and Ground boundary conditions should be preserved as vertices are removed. If False, all boundary conditions are replaced with Outdoors, all window parameters are erased, and this method will execute quickly. If True, an attempt will be made to merge window parameters together across colinear segments, translating simple window parameters to rectangular ones if necessary. Also, existing Ground boundary conditions will be kept. (Default: True). Returns: A new Room2D derived from this one with its colinear vertices removed. """ if not preserve_wall_props: try: # remove colinear vertices from the Room2D new_geo = self.floor_geometry.remove_colinear_vertices(tolerance) except AssertionError as e: # usually a sliver face of some kind raise ValueError( 'Room2D "{}" is degenerate with dimensions less than the ' 'tolerance.\n{}'.format(self.display_name, e)) rebuilt_room = Room2D( self.identifier, new_geo, self.floor_to_ceiling_height, is_ground_contact=self.is_ground_contact, is_top_exposed=self.is_top_exposed) else: ftc_height = self.floor_to_ceiling_height if not self.floor_geometry.has_holes: # only need to evaluate one list pts_3d = self.floor_geometry.vertices pts_2d = self.floor_geometry.polygon2d segs_2d = pts_2d.segments bound_cds = self.boundary_conditions win_pars = self.window_parameters bound_verts, new_bcs, new_w_par = self._remove_colinear_props( pts_3d, pts_2d, segs_2d, bound_cds, win_pars, ftc_height, tolerance) holes = None else: pts_3d = self.floor_geometry.boundary pts_2d = self.floor_geometry.boundary_polygon2d segs_2d = pts_2d.segments st_i = len(pts_3d) bound_cds = self.boundary_conditions[:st_i] win_pars = self.window_parameters[:st_i] bound_verts, new_bcs, new_w_par = self._remove_colinear_props( pts_3d, pts_2d, segs_2d, bound_cds, win_pars, ftc_height, tolerance) holes = [] for i, pts_3d in enumerate(self.floor_geometry.holes): pts_2d = self.floor_geometry.hole_polygon2d[i] segs_2d = pts_2d.segments bound_cds = self.boundary_conditions[st_i:st_i + len(pts_3d)] win_pars = self.window_parameters[st_i:st_i + len(pts_3d)] st_i += len(pts_3d) h_verts, h_bcs, h_w_par = self._remove_colinear_props( pts_3d, pts_2d, segs_2d, bound_cds, win_pars, ftc_height, tolerance) holes.append(h_verts) new_bcs.extend(h_bcs) new_w_par.extend(h_w_par) # create the new Room2D new_geo = Face3D(bound_verts, holes=holes) rebuilt_room = Room2D( self.identifier, new_geo, self.floor_to_ceiling_height, boundary_conditions=new_bcs, window_parameters=new_w_par, is_ground_contact=self.is_ground_contact, is_top_exposed=self.is_top_exposed) # assign overall properties to the rebuilt room rebuilt_room._has_floor = self._has_floor rebuilt_room._has_ceiling = self._has_ceiling rebuilt_room._skylight_parameters = self._skylight_parameters rebuilt_room._display_name = self._display_name rebuilt_room._user_data = self._user_data rebuilt_room._parent = self._parent rebuilt_room._abridged_properties = self._abridged_properties rebuilt_room._properties._duplicate_extension_attr(self._properties) return rebuilt_room
[docs] def remove_short_segments(self, distance, angle_tolerance=1.0): """Get a version of this Room2D with consecutive short segments removed. To patch over the segments, an attempt will first be made to find the intersection of the two neighboring segments. If these two lines are parallel, they will simply be connected with a segment. Properties assigned to the Room2D will be preserved for the segments that are not removed. Args: distance: The maximum length of a segment below which the segment will be considered for removal. angle_tolerance: The max angle difference in degrees that vertices are allowed to differ from one another in order to consider them colinear. (Default: 1). """ # first check if there are contiguous short segments to be removed segs = [self._floor_geometry.boundary_segments] if self._floor_geometry.has_holes: for hole in self._floor_geometry.hole_segments: segs.append(hole) sh_seg_i = [[i for i, s in enumerate(sg) if s.length <= distance] for sg in segs] if len(segs[0]) - len(sh_seg_i[0]) < 3: return None # large distance means the whole Face becomes removed if all(len(s) <= 1 for s in sh_seg_i): return self # no short segments to remove del_seg_i = [] for sh_seg in sh_seg_i: del_seg = set() for i, seg_i in enumerate(sh_seg): test_val = seg_i - sh_seg[i - 1] if test_val == 1 or (seg_i == 0 and test_val < 0): del_seg.add(sh_seg[i - 1]) del_seg.add(seg_i) if 0 in sh_seg and len(sh_seg) - 1 in sh_seg: del_seg.add(0) del_seg.add(len(sh_seg) - 1) del_seg_i.append(sorted(list(del_seg))) if all(len(s) == 0 for s in del_seg_i): return self # there are short segments but they're not contiguous # contiguous short segments found # collect the vertices and indices of properties to be removed a_tol = math.radians(angle_tolerance) prev_i, final_pts, del_prop_i = 0, [], [] for p_segs, del_i in zip(segs, del_seg_i): if len(del_i) != 0: # set up variables to handle getting the last vertex to connect to new_points, in_del, post_del = [], False, False if 0 in del_i and len(p_segs) - 1 in del_i: last_i, in_del = -1, True try: while del_i[last_i] - del_i[last_i - 1] == 1: last_i -= 1 except IndexError: # entire hole to be removed for i in range(len(p_segs)): del_prop_i.append(prev_i + i) p_segs = [] # loop through the segments and delete the short ones for i, lin in enumerate(p_segs): if i in del_i: if not in_del: last_i = i in_del = True del_prop_i.append(prev_i + i) rel_i = i + 1 if i + 1 != len(p_segs) else 0 if rel_i not in del_i: # we are at the end of the deletion # see if we can repair the hole by extending segments l3a, l3b = p_segs[last_i - 1], p_segs[rel_i] l2a = Ray2D(Point2D(l3a.p.x, l3a.p.y), Vector2D(l3a.v.x, l3a.v.y)) l2b = Ray2D(Point2D(l3b.p.x, l3b.p.y), Vector2D(l3b.v.x, l3b.v.y)) v_ang = l2a.v.angle(l2b.v) if v_ang <= a_tol or v_ang >= math.pi - a_tol: # parallel new_points.append(p_segs[last_i].p) del_prop_i.pop(-1) # put back the last property else: # extend lines to the intersection int_pt = self._intersect_line2d_infinite(l2a, l2b) int_pt3 = Point3D(int_pt.x, int_pt.y, self.floor_height) new_points.append(int_pt3) post_del = True in_del = False else: if not post_del: new_points.append(lin.p) post_del = False if post_del: new_points.pop(0) # put back the last property if len(new_points) != 0: final_pts.append(new_points) else: # no short segments to remove on this hole or boundary final_pts.append([lin.p for lin in p_segs]) prev_i += len(p_segs) # create the geometry and convert properties for the new segments holes = None if len(final_pts) == 1 else final_pts[1:] new_geo = Face3D(final_pts[0], self.floor_geometry.plane, holes) new_bcs = self._boundary_conditions[:] new_win = self._window_parameters[:] new_shd = self._shading_parameters[:] new_abs = list(self.air_boundaries) all_props = (new_bcs, new_win, new_shd, new_abs) for prop_list in all_props: for di in reversed(del_prop_i): prop_list.pop(di) # create the final rebuilt Room2D and return it rebuilt_room = Room2D( self.identifier, new_geo, self.floor_to_ceiling_height, new_bcs, new_win, new_shd, self.is_ground_contact, self.is_top_exposed) rebuilt_room._air_boundaries = new_abs rebuilt_room._has_floor = self._has_floor rebuilt_room._has_ceiling = self._has_ceiling rebuilt_room._skylight_parameters = self._skylight_parameters rebuilt_room._display_name = self._display_name rebuilt_room._user_data = self._user_data rebuilt_room._parent = self._parent rebuilt_room._abridged_properties = self._abridged_properties rebuilt_room._properties._duplicate_extension_attr(self._properties) return rebuilt_room
[docs] def subtract_room_2ds(self, room_2ds, tolerance=0.01): """Get (a) version(s) of this Room2D with other Room2Ds subtracted from it. This is useful for resolving overlaps between Room2Ds of the same Story. Args: room_2d: A Room2D that will be subtracted from this Room2D. tolerance: The maximum difference between point values for them to be considered distinct from one another. (Default: 0.01; suitable for objects in Meters). Returns: A list of Room2D for the result of splitting this Room2D with the input line. Will be a list with only the current Room2D if the line does not split it into two or more pieces. """ # first check that the two geometries have the same Z coordinate self_face = self.floor_geometry z_v = self_face[0].z other_faces = [] for room_2d in room_2ds: face2 = room_2d.floor_geometry if abs(self_face[0].z - face2[0].z) > tolerance: new_bound = [Point3D(pt.x, pt.y, z_v) for pt in face2.boundary] new_orig = Point3D(face2[0].x, face2[0].y, z_v) new_plane = Plane(n=face2.plane.n, o=new_orig) new_holes = [[Point3D(p.x, p.y, z_v) for p in h] for h in face2.holes] \ if face2.has_holes else None face2 = Face3D(new_bound, new_plane, new_holes) other_faces.append(face2) # subtract the other Room2Ds from this one ang_tol = math.radians(1) new_geos = self_face.coplanar_difference(other_faces, tolerance, ang_tol) if len(new_geos) == 1 and new_geos[0] is self_face: return [self] # the Face3D did not overlap with one another new_geos.sort(key=lambda x: x.area, reverse=True) # create the final rebuilt Room2Ds and return them new_rooms = [] for i, new_geo in enumerate(new_geos): rm_id = self.identifier if i == 0 else '{}{}'.format(self.identifier, i) rebuilt_room = Room2D( rm_id, new_geo, self.floor_to_ceiling_height, is_ground_contact=self.is_ground_contact, is_top_exposed=self.is_top_exposed) self._match_and_transfer_wall_props(rebuilt_room, tolerance) if i == 0: rebuilt_room._skylight_parameters = self._skylight_parameters rebuilt_room._has_floor = self._has_floor rebuilt_room._has_ceiling = self._has_ceiling rebuilt_room._display_name = self._display_name rebuilt_room._user_data = self._user_data rebuilt_room._parent = self._parent rebuilt_room._abridged_properties = self._abridged_properties rebuilt_room._properties._duplicate_extension_attr(self._properties) new_rooms.append(rebuilt_room) return new_rooms
[docs] def split_with_line(self, line, tolerance=0.01): """Get this Room2D split by a line. If the input line does not intersect this Room2D in a manner that splits it into two or more pieces, a list with only the current room will be returned. Args: line: A LineSegment2D object that will be used to split this Room2D into two or more pieces. tolerance: The maximum difference between point values for them to be considered distinct from one another. (Default: 0.01; suitable for objects in Meters). Returns: A list of Room2D for the result of splitting this Room2D with the input line. Will be a list with only the current Room2D if the line does not split it into two or more pieces. """ # create a 3D version of the line for the closest point calculation if isinstance(line, LineSegment2D): # check if the coordinate values are too high to resolve with tolerance t_up = tolerance * 1e6 if line.p.x > t_up or line.p.y > t_up or line.v.x > t_up or line.v.y > t_up: min_pt, max_pt = self.min, self.max base, hgt = max_pt.x - min_pt.x, max_pt.y - min_pt.y bound_rect = Polygon2D.from_rectangle(min_pt, Vector2D(0, 1), base, hgt) inter_pts = bound_rect.intersect_line_ray(line) if len(inter_pts) == 2: line = LineSegment2D.from_end_points(inter_pts[0], inter_pts[1]) line_3d = LineSegment3D(Point3D(line.p.x, line.p.y, self.floor_height), Vector3D(line.v.x, line.v.y, 0)) else: msg = 'Expected LineSegment2D. Got {}.'.format(type(line)) raise TypeError(msg) # split the Room2D with the line new_geos = self.floor_geometry.split_with_line(line_3d, tolerance) if new_geos is None or len(new_geos) == 1: return [self] # the Face3D did not overlap with one another # create the final Room2Ds return self._create_split_rooms(new_geos, tolerance)
[docs] def split_with_polyline(self, polyline, tolerance=0.01): """Get this Room2D split into two or more Room2Ds by a polyline. If the input polyline does not intersect this Room2D in a manner that splits it into two or more pieces, a list with only the current room will be returned. Args: polyline: A Polyline2D object that will be used to split this Room2D into two or more pieces. tolerance: The maximum difference between point values for them to be considered distinct from one another. (Default: 0.01; suitable for objects in Meters). Returns: A list of Room2D for the result of splitting this Room2D with the input polyline. Will be a list with only the current Room2D if the polyline does not split it into two or more pieces. """ # create a 3D version of the polyline for the closest point calculation if isinstance(polyline, Polyline2D): polyline_3d = Polyline3D( [Point3D(pt.x, pt.y, self.floor_height) for pt in polyline]) else: msg = 'Expected Polyline2D. Got {}.'.format(type(polyline)) raise TypeError(msg) # split the Room2D with the polyline new_geos = self.floor_geometry.split_with_polyline(polyline_3d, tolerance) if new_geos is None or len(new_geos) == 1: return [self] # the Face3D did not overlap with one another # create the final Room2Ds return self._create_split_rooms(new_geos, tolerance)
[docs] def split_with_polygon(self, polygon, tolerance=0.01): """Get this Room2D split into two or more Room2Ds by a polygon. If the input polygon does not intersect this Room2D in a manner that splits it into two or more pieces, a list with only the current room will be returned. Args: polygon: A Polygon2D object that will be used to split this Room2D into two or more pieces. tolerance: The maximum difference between point values for them to be considered distinct from one another. (Default: 0.01; suitable for objects in Meters). Returns: A list of Room2D for the result of splitting this Room2D with the input polygon. Will be a list with only the current Room2D if the polygon does not split it into two or more pieces. """ # create a 3D version of the polygon for the closest point calculation if isinstance(polygon, Polygon2D): face_3d = Face3D( [Point3D(pt.x, pt.y, self.floor_height) for pt in polygon]) else: msg = 'Expected Polygon2D. Got {}.'.format(type(polygon)) raise TypeError(msg) # split the Room2D with the polygon ang_tol = math.radians(1) new_geos, _ = Face3D.coplanar_split( self.floor_geometry, face_3d, tolerance, ang_tol) if new_geos is None or len(new_geos) == 1: return [self] # the Face3D did not overlap with one another # create the final Room2Ds return self._create_split_rooms(new_geos, tolerance)
[docs] def split_with_lines(self, lines, tolerance=0.01): """Get this Room2D split by multiple line segments together. Using this method is distinct from looping over the Room2D.split_with_line in that this method will resolve cases where multiple segments branch out from nodes in a network of input lines. So, if three line segments meet at a point in the middle of this Room2D and each extend past the edges of this Room2D, this method can split the Room2D in 3 parts whereas looping over the Room2D.split_with_line will not do this given that each individual segment cannot split the Room2D. If the input lines together do not intersect this Room2D in a manner that splits it into two or more pieces, a list with only the current room will be returned. Args: lines: A list of LineSegment2D objects that will be used to split this Room2D into two or more pieces. tolerance: The maximum difference between point values for them to be considered distinct from one another. (Default: 0.01; suitable for objects in Meters). Returns: A list of Room2D for the result of splitting this Room2D with the input line. Will be a list with only the current Room2D if the line does not split it into two or more pieces. """ # create 3D versions of the lines for the closest point calculation lines_3d = [] for line in lines: if isinstance(line, LineSegment2D): line_3d = LineSegment3D(Point3D(line.p.x, line.p.y, self.floor_height), Vector3D(line.v.x, line.v.y, 0)) lines_3d.append(line_3d) else: msg = 'Expected LineSegment2D. Got {}.'.format(type(line)) raise TypeError(msg) # split the Room2D with the line new_geos = self.floor_geometry.split_with_lines(lines_3d, tolerance) if new_geos is None or len(new_geos) == 1: return [self] # the Face3D did not overlap with one another # create the final Room2Ds return self._create_split_rooms(new_geos, tolerance)
[docs] def split_through_self_intersection(self, overlap_room=None, tolerance=0.01): """Get a list of non-intersecting Room2Ds if this Room2D intersects itself. If the Room2D does not intersect itself, a list with only the current Room2D instance will be returned. Args: overlap_room: An optional Room2D, which will be used to ensure that the output list includes only the split Room2D with the highest overlap with this Room2D. This is useful when this method is being used as a cleanup operation for another method that accidentally created a self-intersecting shape (eg. remove_short_segments). If None, the output will include all Room2Ds resulting from the splitting of this shape through self-intersection. (Default: None). tolerance: The maximum difference between point values for them to be considered distinct from one another. (Default: 0.01; suitable for objects in Meters). Returns: A list of Room2D for the result of splitting this Room2D. Will be a list with only the current Room2D instance if the Room2D does not intersect itself """ # first, check that the floor geometry intersects itself if not self.floor_geometry.boundary_polygon2d.is_self_intersecting: return [self] # split the room's boundary polygon through its self intersection rm_poly = self.floor_geometry.boundary_polygon2d split_polys = rm_poly.split_through_self_intersection(tolerance) if overlap_room is not None: poly_1 = overlap_room.floor_geometry.boundary_polygon2d ov_areas = [] for poly_2 in split_polys: new_geos = poly_1.boolean_intersect(poly_2, tolerance) if new_geos is None or len(new_geos) == 0: ov_areas.append(0) # the Face3Ds did not overlap with one another ov_areas.append(sum(f.area for f in new_geos)) sort_polys = [p for _, p in sorted(zip(ov_areas, split_polys), key=lambda pair: pair[0])] split_polys = [sort_polys[-1]] # create Face3Ds from the split polygons new_geos = [] z_val, flr_plane = self.floor_height, self.floor_geometry.plane for poly in split_polys: face = Face3D([Point3D(pt.x, pt.y, z_val) for pt in poly], plane=flr_plane) new_geos.append(face) # create the final Room2Ds new_rooms = self._create_split_rooms(new_geos, tolerance) if len(new_rooms) == 1: # preserve the original room identifier new_rooms[0].identifier = self.identifier return new_rooms
def _create_split_rooms(self, face_3ds, tolerance): """Create Room2Ds from Face3Ds that were split from this Room2D.""" # create the Room2Ds new_rooms = [] for i, new_geo in enumerate(face_3ds): rm_id = '{}{}'.format(self.identifier, i) rebuilt_room = Room2D( rm_id, new_geo, self.floor_to_ceiling_height, is_ground_contact=self.is_ground_contact, is_top_exposed=self.is_top_exposed) self._match_and_transfer_wall_props(rebuilt_room, tolerance) rebuilt_room._has_floor = self._has_floor rebuilt_room._has_ceiling = self._has_ceiling rebuilt_room._display_name = self._display_name rebuilt_room._user_data = self._user_data rebuilt_room._parent = self._parent rebuilt_room._abridged_properties = self._abridged_properties rebuilt_room._properties._duplicate_extension_attr(self._properties) new_rooms.append(rebuilt_room) # split the skylights if they exist if self.skylight_parameters is not None: room_faces = [r.floor_geometry for r in new_rooms] new_skys = self.skylight_parameters.split(room_faces, tolerance) for room, sky_par in zip(new_rooms, new_skys): room.skylight_parameters = sky_par return new_rooms
[docs] def check_horizontal(self, tolerance=0.01, raise_exception=True): """Check whether the Room2D's floor geometry is horizontal within a tolerance. Args: tolerance: The maximum difference between z values at which face vertices are considered at different heights. Default: 0.01, suitable for objects in meters. raise_exception: Boolean to note whether a ValueError should be raised if the room floor geometry is not horizontal. """ z_vals = tuple(pt.z for pt in self._floor_geometry.vertices) if max(z_vals) - min(z_vals) <= tolerance: return '' msg = 'Room "{}" is not horizontal to within {} tolerance.'.format( self.display_name, tolerance) if raise_exception: raise ValueError(msg) return msg
[docs] def check_degenerate(self, tolerance=0.01, raise_exception=True, detailed=False): """Check whether the Room2D's floor geometry is degenerate with zero area. Args: tolerance: The minimum difference between the coordinate values of two vertices at which they can be considered equivalent. (Default: 0.01, suitable for objects in meters). raise_exception: If True, a ValueError will be raised if the object intersects with itself. (Default: True). detailed: Boolean for whether the returned object is a detailed list of dicts with error info or a string with a message. (Default: False). Returns: A string with the message or a list with a dictionary if detailed is True. """ degenerate = False try: self.floor_geometry.remove_colinear_vertices(tolerance) except AssertionError: # degenerate geometry found! degenerate = True if degenerate: msg = 'Room2D "{}" has degenerate floor geometry with zero ' \ 'area.'.format(self.display_name) if raise_exception: raise ValueError(msg) full_msg = self._validation_message_child( msg, self, detailed, '100101', error_type='Degenerate Room Geometry') if detailed: return [full_msg] if raise_exception: raise ValueError(full_msg) return full_msg return [] if detailed else ''
[docs] def check_self_intersecting(self, tolerance=0.01, raise_exception=True, detailed=False): """Check whether the Room2D's floor geometry intersects itself (like a bowtie). Note that objects that have duplicate vertices will not be considered self-intersecting and are valid. Args: tolerance: The minimum difference between the coordinate values of two vertices at which they can be considered equivalent. (Default: 0.01, suitable for objects in meters). raise_exception: If True, a ValueError will be raised if the object intersects with itself. (Default: True). detailed: Boolean for whether the returned object is a detailed list of dicts with error info or a string with a message. (Default: False). Returns: A string with the message or a list with a dictionary if detailed is True. """ if self.floor_geometry.is_self_intersecting: msg = 'Room2D "{}" has floor geometry with self-intersecting ' \ 'edges.'.format(self.display_name) try: # see if it is self-intersecting because of a duplicate vertex new_geo = self.floor_geometry.remove_duplicate_vertices(tolerance) if not new_geo.is_self_intersecting: return [] if detailed else '' # valid with removed dup vertex except AssertionError: pass # zero area face; treat it as self-intersecting full_msg = self._validation_message_child( msg, self, detailed, '100102', error_type='Self-Intersecting Room Geometry') if detailed: return [full_msg] if raise_exception: raise ValueError(full_msg) return full_msg return [] if detailed else ''
[docs] def check_window_parameters_valid( self, tolerance=0.01, raise_exception=True, detailed=False): """Check whether the window and skylight parameters produce valid apertures. This means that this Room's windows do not overlap with one another and, in the case of detailed windows, the polygons do not self-intersect. It also means that skylights do not extend past the boundary of the room. Args: tolerance: The minimum difference between the coordinate values of two vertices at which they can be considered equivalent. (Default: 0.01, suitable for objects in meters). raise_exception: Boolean to note whether a ValueError should be raised if the window parameters are not valid. detailed: Boolean for whether the returned object is a detailed list of dicts with error info or a string with a message. (Default: False). Returns: A string with the message or a list with a dictionary if detailed is True. """ detailed = False if raise_exception else detailed msgs = [] checkable_par = (RectangularWindows, DetailedWindows) for i, wp in enumerate(self._window_parameters): if wp is not None and isinstance(wp, checkable_par): msg = wp.check_window_overlaps(tolerance) if msg != '': msgs.append(' Segment ({}) - {}'.format(i, msg)) if isinstance(wp, DetailedWindows): msg = wp.check_self_intersecting(tolerance) if msg != '': msgs.append(' Segment ({}) - {}'.format(i, msg)) if isinstance(self._skylight_parameters, DetailedSkylights): msg = self._skylight_parameters.check_valid_for_face(self.floor_geometry) if msg != '': msgs.append(' Skylights - {}'.format(msg)) msg = self._skylight_parameters.check_overlaps(tolerance) if msg != '': msgs.append(' Skylights - {}'.format(msg)) msg = self._skylight_parameters.check_self_intersecting(tolerance) if msg != '': msgs.append(' Skylights - {}'.format(msg)) if len(msgs) == 0: return [] if detailed else '' full_msg = 'Room2D "{}" contains invalid window parameters.' \ '\n {}'.format(self.display_name, '\n '.join(msgs)) full_msg = self._validation_message_child( full_msg, self, detailed, '100103', error_type='Invalid Window Parameters') if detailed: return [full_msg] if raise_exception: raise ValueError(full_msg) return full_msg
[docs] def to_core_perimeter(self, perimeter_offset, air_boundary=False, tolerance=0.01): """Translate this Room2D into a list of Room2Ds separated by core and perimeter. All of the resulting Room2Ds will have the same properties as this initial Room2D with all windows and boundary conditions conserved. All of the newly-created interior walls between the core and perimeter Room2Ds will have Surface boundary conditions. Args: perimeter_offset: An optional positive number that will be used to offset the perimeter of the all_story_geometry to create core/perimeter zones. If this value is 0, no offset will occur and each story will be represented with a single Room2D per polygon. air_boundary: A boolean to note whether all of the new wall adjacencies should be set to an AirBoundary type. (Default: False). tolerance: The maximum difference between x, y, and z values at which point vertices are considered to be the same. This is also needed as a means to determine which floor geometries are equivalent to one another and should be a part the same Story. Default: 0.01, suitable for objects in meters. Returns: A list of Room2D for core Room2Ds followed by perimeter Room2Ds. If the current Room2D cannot be converted into core and perimeter Room2Ds, a list with the current Room2D instance will be returned. """ # create the floor Face3Ds from this Room2D's floor_geometry tol = tolerance try: perimeter, core = perimeter_core_subfaces( self.floor_geometry, perimeter_offset, tol) new_face3d_array = perimeter + core except Exception: # the generation of the polyskel failed; possibly neg offset return [self] # just use existing floor # create the new Room2D objects from the result parent_zip = ( self.floor_segments_2d, self.boundary_conditions, self.window_parameters, self.shading_parameters, self.air_boundaries ) new_rooms = [] for i, floor_geo in enumerate(new_face3d_array): # determine the segments of the new Room2D if floor_geo.normal.z < 0: # ensure upward-facing Face3D floor_geo = floor_geo.flip() o_p = Plane(Vector3D(0, 0, 1), Point3D(0, 0, floor_geo.plane.o.z)) floor_geo = Face3D(floor_geo.boundary, o_p, floor_geo.holes) new_room_seg = floor_geo.boundary_polygon2d.segments \ if not floor_geo.has_holes \ else floor_geo.boundary_polygon2d.segments + \ tuple(seg for hole in floor_geo.hole_polygon2d for seg in hole.segments) # match the new segments to the existing properties new_bcs, new_win, new_shd, new_abs = [], [], [], [] for new_seg in new_room_seg: p1, p2 = new_seg.p1, new_seg.p2 for seg, bc, wp, sp, ab in zip(*parent_zip): if seg.distance_to_point(p1) <= tol and \ seg.distance_to_point(p2) <= tol: new_bcs.append(bc) new_win.append(wp) new_shd.append(sp) new_abs.append(ab) break else: new_bcs.append(bcs.outdoors) new_win.append(None) new_shd.append(None) new_abs.append(False) new_id = '{}_{}'.format(self.identifier, i) new_room = Room2D( new_id, floor_geo, self.floor_to_ceiling_height, new_bcs, new_win, new_shd, self.is_ground_contact, self.is_top_exposed, tol) new_room.air_boundaries = new_abs new_room._has_floor = self._has_floor new_room._has_ceiling = self._has_ceiling new_room.display_name = '{}_{}'.format(self.display_name, i) new_room._properties._duplicate_extension_attr(self._properties) new_rooms.append(new_room) # re-assign skylights if they exist if self.skylight_parameters is not None: room_faces = [r.floor_geometry for r in new_rooms] new_skys = self.skylight_parameters.split(room_faces, tol) for room, sky_par in zip(new_rooms, new_skys): room.skylight_parameters = sky_par # solve adjacency between the Room2Ds new_rooms = Room2D.intersect_adjacency(new_rooms, tol) adj_info = Room2D.solve_adjacency(new_rooms, tol) if air_boundary: # set air boundary type if requested for room_pair in adj_info: for room_adj in room_pair: room, wall_i = room_adj room.set_air_boundary(wall_i) return new_rooms
[docs] def to_honeybee( self, multiplier=1, add_plenum=False, tolerance=0.01, enforce_bc=True, enforce_solid=True): """Convert Dragonfly Room2D to a Honeybee Room. Args: multiplier: An integer greater than 0 that denotes the number of times the room is repeated. You may want to set this differently depending on whether you are exporting each room as its own geometry (in which case, this should be 1) or you only want to simulate the "unique" room once and have the results multiplied. Default: 1. add_plenum: Boolean to indicate whether ceiling/floor plenums should be auto-generated for the Room in which case this output will be a list instead of a single Room. The height of the ceiling plenum will be autocalculated as the difference between the Room2D ceiling height and Story ceiling height. The height of the floor plenum will be autocalculated as the difference between the Room2D floor height and Story floor height. (Default: False). tolerance: The minimum distance in z values of floor_height and floor_to_ceiling_height at which adjacent Faces will be split. This is also used in the generation of Windows, and to check if the Room ceiling is adjacent to the upper floor of the Story before generating a plenum. Default: 0.01, suitable for objects in meters. enforce_bc: Boolean to note whether an exception should be raised if apertures are assigned to Wall with an illegal boundary conditions (True) or if the invalid boundary condition should be replaced with an Outdoor boundary condition (False). (Default: True). enforce_solid: Boolean to note whether the room should be translated as a solid extrusion whenever translating the room with custom roof geometry produces a non-solid result (True) or the non-solid room geometry should be allowed to remain in the result (False). The latter is useful for understanding why a particular roof geometry has produced a non-solid result. (Default: True). Returns: A tuple with the two items below. * hb_room -- If add_plenum is False, this will be honeybee-core Room representing the dragonfly Room2D. If the add_plenum argument is True, this item will be a list of honeybee-core Rooms, with the hb_room as the first item, and up to two additional items: * ceil_plenum -- A honeybee-core Room representing the ceiling plenum. If there isn't enough space between the Story floor_to_floor_height and the Room2D floor_to_ceiling height, this item will be None. * floor_plenum -- A honeybee-core Room representing the floor plenum. If there isn't enough space between the Story floor_height and the Room2D floor_height, this item will be None. * adjacencies -- A list of tuples that record any adjacencies that should be set on the level of the Story to which the Room2D belongs. Each tuple will have a honeybee Face as the first item and a tuple of Surface.boundary_condition_objects as the second item. """ # create the honeybee Room has_roof = False if self._parent is not None: # get a roof specification for the room roof_spec = self._parent._room_roofs(self, tolerance) # generate the room volume from the slanted roof if roof_spec is not None: room_polyface, roof_face_i = \ self._room_volume_with_roof(roof_spec, tolerance) if room_polyface is None: # complete failure to interpret roof has_roof = False elif enforce_solid and not room_polyface.is_solid: has_roof = False else: has_roof = True if not has_roof: # generate the Room volume normally through extrusion room_polyface = Polyface3D.from_offset_face( self._floor_geometry, self.floor_to_ceiling_height) roof_face_i = [-1] # create the honeybee Room and set the RoofCeiling faces hb_room = Room.from_polyface3d( self.identifier, room_polyface, ground_depth=self.floor_height - 1) roof_faces = [] for i in roof_face_i: rfc = hb_room[i] rfc.type = ftyp.roof_ceiling roof_faces.append(rfc) # assign BCs and record any Surface conditions to be set on the story level adjacencies = [] for i, bc in enumerate(self._boundary_conditions): if not isinstance(bc, Surface): hb_room[i + 1]._boundary_condition = bc else: adjacencies.append((hb_room[i + 1], bc.boundary_condition_objects)) # assign windows, shading, and air boundary properties to walls for i, glz_par in enumerate(self._window_parameters): if glz_par is not None: hb_face = hb_room[i + 1] try: glz_par.add_window_to_face(hb_face, tolerance) except AssertionError as e: if enforce_bc: raise e hb_room[i + 1]._boundary_condition = bcs.outdoors hb_room[i + 1].remove_sub_faces() glz_par.add_window_to_face(hb_face, tolerance) if has_roof and isinstance(glz_par, _AsymmetricBase): valid_ap = [] for ap in hb_face._apertures: if hb_face.geometry._is_sub_face(ap.geometry): valid_ap.append(ap) if len(hb_face._apertures) != len(valid_ap): hb_face.remove_apertures() hb_face.add_apertures(valid_ap) for i, shd_par in enumerate(self._shading_parameters): if shd_par is not None: shd_par.add_shading_to_face(hb_room[i + 1], tolerance) if self._air_boundaries is not None: for i, a_bnd in enumerate(self._air_boundaries): if a_bnd: hb_room[i + 1].type = ftyp.air_boundary # ensure matching adjacent Faces across the Story if self._parent is not None and not has_roof: new_faces = self._split_walls_along_height(hb_room, tolerance, add_plenum) if len(new_faces) != len(hb_room): # rebuild the room with split surfaces hb_room = Room(self.identifier, new_faces, tolerance, 0.1) # update adjacencies with the new split face for i, adj in enumerate(adjacencies): face_id = adj[0].identifier for face in hb_room.faces: if face.identifier == face_id: adjacencies[i] = (face, adj[1]) break # set the story, multiplier, display_name, and user_data if self.has_parent: hb_room.story = self.parent.display_name hb_room.multiplier = multiplier hb_room._display_name = self._display_name hb_room._user_data = self._user_data # assign boundary conditions for the roof and floor try: hb_room[0].boundary_condition = bcs.adiabatic for rf in roof_faces: rf.boundary_condition = bcs.adiabatic except AttributeError: pass # honeybee_energy is not loaded and Adiabatic type doesn't exist if self._is_ground_contact: hb_room[0].boundary_condition = bcs.ground if self._is_top_exposed: for rf in roof_faces: rf.boundary_condition = bcs.outdoors # transfer any extension properties assigned to the Room2D and return result hb_room._properties = self.properties.to_honeybee(hb_room) if not add_plenum or has_roof: if self._skylight_parameters is not None: if self._is_top_exposed: for rf in roof_faces: self._skylight_parameters.add_skylight_to_face(rf, tolerance) return hb_room, adjacencies # add plenums if requested and return results hb_plenums = self._honeybee_plenums(hb_room, tolerance=tolerance) for hb_plenum in hb_plenums: # transfer the parent's construction set hb_plenum._properties = self.properties.to_honeybee(hb_plenum) hb_plenum.exclude_floor_area = True if self.has_parent: hb_plenum.story = self.parent.display_name try: # set the program to unconditioned plenum and assign infiltration hb_plenum.properties.energy.program_type = None hb_plenum.properties.energy.hvac = None hb_plenum.properties.energy._shw = None hb_plenum.properties.energy.infiltration = \ hb_room.properties.energy.infiltration except AttributeError: pass # honeybee-energy is not loaded; ignore all these energy properties # set the skylights if top is exposed and there's no ceiling plenum if self._skylight_parameters is not None: if self._is_top_exposed: if len(hb_plenums) == 0: self._skylight_parameters.add_skylight_to_face( hb_room[-1], tolerance) elif len(hb_plenums) == 1 and \ hb_plenums[0].identifier.endswith('floor_plenum'): self._skylight_parameters.add_skylight_to_face( hb_room[-1], tolerance) # return the rooms and the adjacency information return [hb_room] + hb_plenums, adjacencies
[docs] def to_dict(self, abridged=False, included_prop=None): """Return Room2D as a dictionary. Args: abridged: Boolean to note whether the extension properties of the object (ie. program_type, construction_set) should be included in detail (False) or just referenced by identifier (True). Default: False. included_prop: List of properties to filter keys that must be included in output dictionary. For example ['energy'] will include 'energy' key if available in properties to_dict. By default all the keys will be included. To exclude all the keys from extensions use an empty list. """ base = {'type': 'Room2D'} base['identifier'] = self.identifier base['display_name'] = self.display_name if abridged and self._abridged_properties is not None: base['properties'] = self._abridged_properties else: base['properties'] = self.properties.to_dict(abridged, included_prop) base['floor_boundary'] = [(pt.x, pt.y) for pt in self._floor_geometry.boundary] if self._floor_geometry.has_holes: base['floor_holes'] = \ [[(pt.x, pt.y) for pt in hole] for hole in self._floor_geometry.holes] base['floor_height'] = self._floor_geometry[0].z base['floor_to_ceiling_height'] = self._floor_to_ceiling_height base['is_ground_contact'] = self._is_ground_contact base['is_top_exposed'] = self._is_top_exposed if not self._has_floor: base['has_floor'] = self._has_floor if not self._has_ceiling: base['has_ceiling'] = self._has_ceiling bc_dicts = [] for bc in self._boundary_conditions: if isinstance(bc, Outdoors) and 'energy' in base['properties']: bc_dicts.append(bc.to_dict(full=True)) else: bc_dicts.append(bc.to_dict()) base['boundary_conditions'] = bc_dicts if not all((param is None for param in self._window_parameters)): base['window_parameters'] = [] for glz in self._window_parameters: val = glz.to_dict() if glz is not None else None base['window_parameters'].append(val) if not all((param is None for param in self._shading_parameters)): base['shading_parameters'] = [] for shd in self._shading_parameters: val = shd.to_dict() if shd is not None else None base['shading_parameters'].append(val) if self._air_boundaries is not None: if not all((not param for param in self._air_boundaries)): base['air_boundaries'] = self._air_boundaries if self._skylight_parameters is not None: base['skylight_parameters'] = self.skylight_parameters.to_dict() if self.user_data is not None: base['user_data'] = self.user_data return base
@property def to(self): """Room2D writer object. Use this method to access Writer class to write the room2d in other formats. """ return writer
[docs] @staticmethod def find_adjacency_gaps(room_2ds, gap_distance=0.1, tolerance=0.01): """Identify gaps between a list of Room2Ds that are smaller than a gap_distance. This is useful for identifying cases where gaps can result in failed intersections between Room2Ds of adjacent stories or failed adjacency solving within each story. Args: room_2ds: A list of Room2Ds for which adjacency gaps will be identified. gap_distance: The maximum distance between two Room2Ds that is considered an adjacency gap. Differences between Room2Ds that are higher than this distance are considered meaningful gaps to be preserved. This value should be higher than the tolerance to be meaningful. (Default: 0.1, suitable for objects in meters). tolerance: The minimum difference between the coordinate values at which point they are considered equivalent. (Default: 0.01, suitable for objects in meters). Returns: A list of Point2Ds that note the location of any gaps between the input room_2ds, which are larger than the tolerance but less than the gap_distance. """ gap_points = [] for i, room_1 in enumerate(room_2ds): try: for room_2 in room_2ds[i + 1:]: poly_1 = room_1._floor_geometry.boundary_polygon2d poly_2 = room_2._floor_geometry.boundary_polygon2d if not Polygon2D.overlapping_bounding_rect( poly_1, poly_2, gap_distance): continue # no overlap in bounding rect; gap impossible # check the first polygon against the second for pt_1 in poly_1: pt_dist = poly_2.distance_from_edge_to_point(pt_1) if tolerance < pt_dist <= gap_distance: gap_points.append(pt_1) # check the second polygon against the first for pt_2 in poly_2: pt_dist = poly_1.distance_from_edge_to_point(pt_2) if tolerance < pt_dist <= gap_distance: gap_points.append(pt_2) except IndexError: pass # we have reached the end of the list of rooms return gap_points
[docs] @staticmethod def solve_adjacency(room_2ds, tolerance=0.01, resolve_window_conflicts=True): """Solve for all adjacencies between a list of input Room2Ds. Args: room_2ds: A list of Room2Ds for which adjacencies will be solved. tolerance: The minimum difference between the coordinate values of two faces at which they can be considered adjacent. (Default: 0.01, suitable for objects in meters). resolve_window_conflicts: Boolean to note whether conflicts between window parameters of adjacent segments should be resolved during adjacency setting or an error should be raised about the mismatch. Resolving conflicts will default to the window parameters with the larger are and assign them to the other segment. (Default: True). Returns: A list of tuples with each tuple containing 2 sub-tuples for wall segments paired in the process of solving adjacency. Sub-tuples have the Room2D as the first item and the index of the adjacent wall as the second item. This data can be used to assign custom properties to the new adjacent walls (like assigning custom window parameters for interior windows, assigning air boundaries, or custom boundary conditions). """ rwc = resolve_window_conflicts adj_info = [] for i, room_1 in enumerate(room_2ds): try: for room_2 in room_2ds[i + 1:]: if not Polygon2D.overlapping_bounding_rect( room_1._floor_geometry.boundary_polygon2d, room_2._floor_geometry.boundary_polygon2d, tolerance): continue # no overlap in bounding rect; adjacency impossible for j, seg_1 in enumerate(room_1.floor_segments_2d): for k, seg_2 in enumerate(room_2.floor_segments_2d): if not isinstance(room_2._boundary_conditions[k], Surface): if seg_1.distance_to_point(seg_2.p1) <= tolerance and \ seg_1.distance_to_point(seg_2.p2) <= tolerance: # set the boundary conditions of the segments room_1.set_adjacency(room_2, j, k, rwc) adj_info.append(((room_1, j), (room_2, k))) break except IndexError: pass # we have reached the end of the list of rooms return adj_info
[docs] @staticmethod def find_adjacency(room_2ds, tolerance=0.01): """Get a list with all adjacent pairs of segments between input Room2Ds. Note that this method does not change any boundary conditions of the input Room2Ds or mutate them in any way. It's purely a geometric analysis of the segments between Room2Ds. Args: room_2ds: A list of Room2Ds for which adjacencies will be solved. tolerance: The minimum difference between the coordinate values of two faces at which they can be considered adjacent. (Default: 0.01, suitable for objects in meters). Returns: A list of tuples for each discovered adjacency. Each tuple contains 2 sub-tuples with two elements. The first element is the Room2D and the second is the index of the wall segment that is adjacent. """ adj_info = [] # lists of adjacencies to track for i, room_1 in enumerate(room_2ds): try: for room_2 in room_2ds[i + 1:]: if not Polygon2D.overlapping_bounding_rect( room_1._floor_geometry.boundary_polygon2d, room_2._floor_geometry.boundary_polygon2d, tolerance): continue # no overlap in bounding rect; adjacency impossible for j, seg_1 in enumerate(room_1.floor_segments_2d): for k, seg_2 in enumerate(room_2.floor_segments_2d): if seg_1.distance_to_point(seg_2.p1) <= tolerance and \ seg_1.distance_to_point(seg_2.p2) <= tolerance: adj_info.append(((room_1, j), (room_2, k))) break except IndexError: pass # we have reached the end of the list of rooms return adj_info
[docs] @staticmethod def find_adjacency_by_guide_lines(room_2ds, lines, tolerance=0.01): """Get adjacent pairs of Room2Ds segments that lie along specified guide lines. Note that this method does not change any boundary conditions of the input Room2Ds or mutate them in any way. It's purely a geometric analysis of the segments between Room2Ds and the input lines. Args: room_2ds: A list of Room2Ds for which adjacencies will be solved. lines: A list of LineSegment2D objects to note which adjacencies along all of the room_2ds should be returned. tolerance: The minimum difference between the coordinate values of two faces at which they can be considered adjacent. (Default: 0.01, suitable for objects in meters). Returns: A list of tuples for each discovered adjacency that lies along the input lines. Each tuple contains 2 sub-tuples with two elements. The first element is the Room2D and the second is the index of the wall segment that is adjacent. """ adj_info = [] # lists of adjacencies to track for i, room_1 in enumerate(room_2ds): try: for room_2 in room_2ds[i + 1:]: if not Polygon2D.overlapping_bounding_rect( room_1._floor_geometry.boundary_polygon2d, room_2._floor_geometry.boundary_polygon2d, tolerance): continue # no overlap in bounding rect; adjacency impossible for j, seg_1 in enumerate(room_1.floor_segments_2d): for k, seg_2 in enumerate(room_2.floor_segments_2d): if seg_1.distance_to_point(seg_2.p1) <= tolerance and \ seg_1.distance_to_point(seg_2.p2) <= tolerance: if Room2D._seg_on_guide_lines(seg_1, lines, tolerance): adj_info.append(((room_1, j), (room_2, k))) break except IndexError: pass # we have reached the end of the list of rooms return adj_info
[docs] @staticmethod def intersect_adjacency(room_2ds, tolerance=0.01, preserve_wall_props=True): """Intersect the line segments of an array of Room2Ds to ensure matching walls. Also note that this method does not actually set the walls that are next to one another to be adjacent. The solve_adjacency method must be used for this after running this method. Args: room_2ds: A list of Room2Ds for which adjacent segments will be intersected. tolerance: The minimum difference between the coordinate values of two faces at which they can be considered adjacent. Default: 0.01, suitable for objects in meters. preserve_wall_props: Boolean to note whether existing window parameters, shading parameters and boundary conditions should be preserved as vertices are added during intersection. If False, all boundary conditions are replaced with Outdoors, all window parameters are erased, and this method will execute quickly. If True, an attempt will be made to split window parameters new across colinear segments. Existing boundary conditions will also be kept. (Default: True). Returns: An array of Room2Ds that have been intersected with one another. """ # keep track of all data needed to map between 2D and 3D space master_plane = room_2ds[0].floor_geometry.plane move_dists = [] is_holes = [] polygon_2ds = [] tol = tolerance # map all Room geometry into the same 2D space for room in room_2ds: # ensure all starting room heights match dist = master_plane.o.z - room.floor_height move_dists.append(dist) # record all distances moved is_holes.append(False) # record that first Polygon doesn't have holes polygon_2ds.append(room._floor_geometry.boundary_polygon2d) # of there are holes in the face, add them as their own polygons if room._floor_geometry.has_holes: for hole in room._floor_geometry.hole_polygon2d: move_dists.append(dist) # record all distances moved is_holes.append(True) # record that first Polygon doesn't have holes polygon_2ds.append(hole) # intersect the Room2D polygons within the 2D space int_poly = Polygon2D.intersect_polygon_segments(polygon_2ds, tol) # convert the resulting coordinates back to 3D space face_pts = [] for poly, dist, is_hole in zip(int_poly, move_dists, is_holes): pt_3d = [master_plane.xy_to_xyz(pt) for pt in poly] if dist != 0: pt_3d = [Point3D(pt.x, pt.y, pt.z - dist) for pt in pt_3d] if not is_hole: face_pts.append((pt_3d, [])) else: face_pts[-1][1].append(pt_3d) # rebuild all of the floor geometries to the input Room2Ds intersected_rooms = [] for i, face_loops in enumerate(face_pts): if len(face_loops[1]) == 0: # no holes new_geo = Face3D(face_loops[0], room_2ds[i].floor_geometry.plane) else: # ensure holes are included new_geo = Face3D(face_loops[0], room_2ds[i].floor_geometry.plane, face_loops[1]) rebuilt_room = Room2D( room_2ds[i].identifier, new_geo, room_2ds[i].floor_to_ceiling_height, is_ground_contact=room_2ds[i].is_ground_contact, is_top_exposed=room_2ds[i].is_top_exposed) rebuilt_room._has_floor = room_2ds[i]._has_floor rebuilt_room._has_ceiling = room_2ds[i]._has_ceiling rebuilt_room._skylight_parameters = room_2ds[i].skylight_parameters rebuilt_room._display_name = room_2ds[i]._display_name rebuilt_room._user_data = None if room_2ds[i].user_data is None else \ room_2ds[i].user_data.copy() rebuilt_room._parent = room_2ds[i]._parent rebuilt_room._abridged_properties = room_2ds[i]._abridged_properties rebuilt_room._properties._duplicate_extension_attr(room_2ds[i]._properties) intersected_rooms.append(rebuilt_room) # transfer the wall properties if requested if preserve_wall_props: for orig_r, new_r in zip(room_2ds, intersected_rooms): orig_r._match_and_transfer_wall_props(new_r, tolerance) return tuple(intersected_rooms)
[docs] @staticmethod def group_by_adjacency(rooms): """Group Room2Ds together that are connected by adjacencies. This is useful for separating rooms in the case where a Story contains multiple towers or sections that are separated by outdoor boundary conditions. Args: rooms: A list of Room2Ds to be grouped by their adjacency. Returns: A list of list with each sub-list containing rooms that share adjacencies. """ return Room2D._adjacency_grouping(rooms, Room2D._find_adjacent_rooms)
[docs] @staticmethod def group_by_air_boundary_adjacency(rooms): """Group Room2Ds together that share air boundaries. This is useful for understanding the radiant enclosures that will exist when a model is exported to EnergyPlus. Args: rooms: A list of Room2Ds to be grouped by their air boundary adjacency. Returns: A list of list with each sub-list containing Room2Ds that share adjacent air boundaries. If a Room has no air boundaries it will the the only item within its sub-list. """ return Room2D._adjacency_grouping( rooms, Room2D._find_adjacent_air_boundary_rooms)
[docs] @staticmethod def join_room_2ds(room_2ds, min_separation=0, tolerance=0.01): """Join Room2Ds together that are touching one another within a min_separation. When the min_separation is less than or equal to the tolerance, all properties of segments for the input Room2Ds will be preserved. When the min_separation is larger than the tolerance, an attempt is made to preserve all wall properties but there is a risk of losing some windows just in the region where two Room2Ds are joined together across a gap between them. This risk can be overcome by inserting Room2D vertices around where the gap will be crossed between that Room2D and the other Room2D. The largest Room2D that is identified within each connected group will determine the extension properties of the resulting Room2D. Skylights will be merged across rooms if they are of the same type or if they are None. Args: room_2ds: A list of Room2Ds which will be joined together where they touch one another. min_separation: A number for the minimum distance between Room2Ds that is considered a meaningful separation. Gaps between Room2Ds that are less than this distance will result in the Room2Ds being joined across the gap. When the input Room2Ds have floor_geometry representing the boundaries defined by the interior wall finishes, this input can be thought of as the maximum interior wall thickness. When Room2Ds are perfectly touching one another within the tolerance (with Room2D floor_geometry drawn to the center lines of interior walls), this value can be set to zero or anything less than or equal to the tolerance. Doing so will yield a cleaner result for the boundary, which will be faster and more reliable. Note that care should be taken not to set this value higher than the length of any meaningful exterior wall segments. Otherwise, the exterior segments will be ignored in the result. This can be particularly dangerous around curved exterior walls that have been planarized through subdivision into small segments. (Default: 0). tolerance: The minimum distance between a vertex and the polygon boundary at which point the vertex is considered to lie on the polygon. (Default: 0.01, suitable for objects in meters). """ # get the horizontal boundaries around the input Room2Ds h_bnds = Room2D.grouped_horizontal_boundary(room_2ds, min_separation, tolerance) if len(h_bnds) == len(room_2ds): # no Room2Ds to join; return them as they are return room_2ds # ensure Room2D vertices at the boundary exist if min_separation <= tolerance: room_2ds = Room2D.intersect_adjacency(room_2ds, tolerance) else: # we have to figure out if new vertices were added to cross the boundary # gather all vertices across the horizontal boundaries bnd_verts = [] for h_bnd in h_bnds: bnd_verts.extend([Point2D(pt.x, pt.y) for pt in h_bnd.boundary]) if h_bnd.has_holes: for hole in h_bnd.holes: bnd_verts.extend([Point2D(pt.x, pt.y) for pt in hole]) # loop through rooms and identify vertices to insert inter_rooms = [] search_dist = tolerance * 2 for room in room_2ds: floor_segs = [room.floor_geometry.boundary_polygon2d.segments] if room.floor_geometry.has_holes: for hole in room.floor_geometry.hole_polygon2d: floor_segs.append(hole.segments) pts_2d, edit_code = [], [] for loop in floor_segs: loop_pts_2d = [] for seg in loop: loop_pts_2d.append(seg.p1) edit_code.append('K') for bnd_pt in bnd_verts: if seg.distance_to_point(bnd_pt) <= search_dist: if not seg.p1.is_equivalent(bnd_pt, tolerance) and \ not seg.p2.is_equivalent(bnd_pt, tolerance): loop_pts_2d.append(bnd_pt) # vertex to insert ! edit_code.append('A') pts_2d.append(loop_pts_2d) edit_code = ''.join(edit_code) if 'A' in edit_code: # room geometry must be updated room = room.duplicate() # duplicate to avoid editing original geo z_v = room.floor_height pts_3d = [] for loop in pts_2d: pts_3d.append([Point3D(pt.x, pt.y, z_v) for pt in loop]) new_geo = Face3D(pts_3d[0]) if len(pts_3d) == 1 else \ Face3D(pts_3d[0], holes=pts_3d[1:]) room.update_floor_geometry(new_geo, edit_code, search_dist) inter_rooms.append(room) room_2ds = inter_rooms # join the Room2Ds according to the horizontal boundaries that were found joined_rooms = [] for h_bnd in h_bnds: bnd_p_gon = Polygon2D([Point2D(pt.x, pt.y) for pt in h_bnd.boundary]) h_p = None if h_bnd.has_holes: h_p = [] for hole in h_bnd.holes: h_p.append(Polygon2D([Point2D(pt.x, pt.y) for pt in hole])) new_room = Room2D.join_by_boundary( room_2ds, bnd_p_gon, h_p, tolerance=tolerance) joined_rooms.append(new_room) return joined_rooms
[docs] @staticmethod def join_by_boundary( room_2ds, polygon, hole_polygons=None, floor_to_ceiling_height=None, identifier=None, display_name=None, tolerance=0.01): """Join several Room2D together using a boundary Polygon as a guide. All properties of segments along the boundary polygon will be preserved. The largest Room2D that is identified within the boundary polygon will determine the extension properties of the resulting Room unless the supplied identifier matches an existing Room2D inside the polygon. Skylights will be merged if they are of the same type or if they are None. It is recommended that the Room2Ds be aligned to the boundaries of the polygon and duplicate vertices be removed before passing them through this method. However, colinear vertices should not be removed where possible. This helps ensure that relevant Room2D segments are colinear with the polygon and so they can influence the result. Args: room_2ds: A list of Room2Ds which will be joined together using the polygon. polygon: A ladybug_geometry Polygon2D which will become the boundary of the output joined Room2D. hole_polygons: An optional list of hole polygons, which will add holes into the output joined Room2D polygon. (Default: None). floor_to_ceiling_height: An optional number to set the floor-to-ceiling height of the resulting Room2D. If None, it will be the maximum of the Room2Ds that are found inside the polygon, which ensures that all window geometries are included in the output. If specified and it is lower than the maximum Room2D height, any detailed windows will be automatically trimmed to accommodate the new floor-to-ceiling height. (Default: None). identifier: An optional text string for the identifier of the new joined Room2D. If this matches an existing Room2D inside of the polygon, the existing Room2D will be used to set the extension properties of the output Room2D. If None, the identifier and extension properties of the output Room2D will be those of the largest Room2D found inside of the polygon. (Default: None). display_name: An optional text string for the display_name of the new joined Room2D. If None, the display_name will be taken from the largest existing Room2D inside the polygon or the existing Room2D matching the identifier above. (Default: None). tolerance: The minimum distance between a vertex and the polygon boundary at which point the vertex is considered to lie on the polygon. (Default: 0.01, suitable for objects in meters). """ tol = tolerance # ensure that all polygons are counterclockwise polygon = polygon.reverse() if polygon.is_clockwise else polygon if hole_polygons is not None: cc_hole_polygons = [] for p in hole_polygons: p = p.reverse() if p.is_clockwise else p cc_hole_polygons.append(p) hole_polygons = cc_hole_polygons # identify all Room2Ds inside of the polygon rel_rooms, rel_ids, rel_a, rel_fh, rel_ch = [], [], [], [], [] test_vec = Vector2D(0.99, 0.01) for room in room_2ds: if room.floor_geometry.is_convex: rm_pt = room.center else: rm_pt_3d = room.floor_geometry._point_on_face(tol) rm_pt = Point2D(rm_pt_3d.x, rm_pt_3d.y) if polygon.is_point_inside_bound_rect(rm_pt, test_vec): rel_rooms.append(room) rel_ids.append(room.identifier) rel_a.append(room.floor_area) rel_fh.append(room.floor_height) rel_ch.append(room.floor_to_ceiling_height) # if no rooms are inside the polygon, just return a dummy room from the polygon if len(rel_rooms) == 0: fh = sum([r.floor_height for r in room_2ds]) / len(room_2ds) ftc = sum([r.floor_to_ceiling_height for r in room_2ds]) / len(room_2ds) \ if floor_to_ceiling_height is None else floor_to_ceiling_height bound_verts = [Point3D(p.x, p.y, fh) for p in polygon.vertices] all_hole_verts = None if hole_polygons is not None and len(hole_polygons) != 0: all_hole_verts = [] for hole in hole_polygons: all_hole_verts.append([Point3D(p.x, p.y, fh) for p in hole.vertices]) new_geo = Face3D(bound_verts, holes=all_hole_verts) r_id = clean_and_id_string('Room') if identifier is None else identifier return Room2D(r_id, new_geo, ftc) # determine the new floor heights using max/average across relevant rooms new_flr_height = sum(rel_fh) / len(rel_fh) max_ftc = max(rel_ch) new_ftc = max_ftc if floor_to_ceiling_height is None else floor_to_ceiling_height # determine a primary room to set help set properties or the resulting room if identifier is None or identifier not in rel_ids: # find the largest room of the relevant rooms sort_inds = [i for _, i in sorted(zip(rel_a, range(len(rel_a))))] primary_room = rel_rooms[sort_inds[-1]] if identifier is None: identifier = primary_room.identifier else: # draw properties from the room with the matching identifier for r_id, rm in zip(rel_ids, rel_rooms): if r_id == identifier: primary_room = rm break if display_name is None: display_name = primary_room.display_name # gather all segments and properties of relevant rooms rel_segs, rel_bcs, rel_win, rel_shd, rel_abs = [], [], [], [], [] for room in rel_rooms: rel_segs.extend(room.floor_segments_2d) rel_bcs.extend(room.boundary_conditions) rel_shd.extend(room.shading_parameters) rel_abs.extend(room.air_boundaries) w_par = room.window_parameters in_range = new_ftc - tol < room.floor_to_ceiling_height < new_ftc + tol if not in_range: # adjust window ratios to preserve area new_w_par = [] for i, wp in enumerate(w_par): if isinstance(wp, SimpleWindowRatio): w_area = wp.area_from_segment( rel_segs[i], room.floor_to_ceiling_height) new_ratio = w_area / (new_ftc * rel_segs[i].length) new_wp = wp.duplicate() new_wp._window_ratio = new_ratio if new_ratio <= 0.99 else 0.99 new_w_par.append(new_wp) else: new_w_par.append(wp) w_par = new_w_par rel_win.extend(w_par) # find all of the Room2Ds segments that lie on each polygon segment new_bcs, new_win, new_shd, new_abs = [], [], [], [] bound_verts = Room2D._segments_along_polygon( polygon, rel_segs, rel_bcs, rel_win, rel_shd, rel_abs, new_bcs, new_win, new_shd, new_abs, new_flr_height, tol) if hole_polygons is not None and len(hole_polygons) != 0: all_hole_verts = [] for hole in hole_polygons: hole_verts = Room2D._segments_along_polygon( hole, rel_segs, rel_bcs, rel_win, rel_shd, rel_abs, new_bcs, new_win, new_shd, new_abs, new_flr_height, tol) all_hole_verts.append(hole_verts) new_geo = Face3D(bound_verts, holes=all_hole_verts) else: new_geo = Face3D(bound_verts) # merge skylights across the input rooms if they are of the same type new_sky_lights, new_areas = [], [] for room in rel_rooms: if room.skylight_parameters is not None: new_sky_lights.append(room.skylight_parameters) new_areas.append(room.floor_area) new_sky_light = None if all(isinstance(sl, DetailedSkylights) for sl in new_sky_lights): try: new_polys = new_sky_lights[0].polygons new_is_dr = new_sky_lights[0].are_doors for sl in new_sky_lights[1:]: new_polys += sl.polygons new_is_dr += sl.are_doors new_sky_light = DetailedSkylights(new_polys, new_is_dr) except IndexError: pass # skylight with no polygons elif all(isinstance(sl, GriddedSkylightArea) for sl in new_sky_lights): new_area = sum(sl.skylight_area for sl in new_sky_lights) new_sky_light = GriddedSkylightArea(new_area) elif all(isinstance(sl, GriddedSkylightRatio) for sl in new_sky_lights): zip_obj = zip(new_sky_lights, new_areas) new_area = sum(sl.skylight_ratio * fa for sl, fa in zip_obj) new_ratio = new_area / sum(room.floor_area for room in rel_rooms) new_sky_light = GriddedSkylightRatio(new_ratio) # merge all segments and properties into a single Room2D new_room = Room2D( identifier, new_geo, new_ftc, new_bcs, new_win, new_shd, primary_room.is_ground_contact, primary_room.is_top_exposed, tol) new_room.has_floor = primary_room.has_floor new_room.has_ceiling = primary_room.has_ceiling new_room.skylight_parameters = new_sky_light new_room.air_boundaries = new_abs new_room.display_name = display_name new_room._properties._duplicate_extension_attr(primary_room._properties) # if the floor-to-ceiling height is lower than the max, re-trim windows if new_ftc < max_ftc: new_w_pars = [] for w_par, seg in zip(new_room._window_parameters, new_room.floor_segments): if isinstance(w_par, DetailedWindows): new_w_par = w_par.adjust_for_segment(seg, new_ftc, tolerance) else: new_w_par = w_par new_w_pars.append(new_w_par) new_room._window_parameters = new_w_pars return new_room
[docs] @staticmethod def grouped_horizontal_boundary(room_2ds, min_separation=0, tolerance=0.01): """Get a list of Face3D for the horizontal boundary around several Room2Ds. This method will attempt to produce a boundary that follows along the walls of the Room2Ds and it is not suitable for groups of Room2Ds that overlap one another in plan. This method may return an empty list if the min_separation is so large that a continuous boundary could not be determined or if overlaps between input Room2Ds result in failure. Args: room_2ds: A list of Room2Ds for which the horizontal boundary will be computed. min_separation: A number for the minimum distance between Room2Ds that is considered a meaningful separation. Gaps between Room2Ds that are less than this distance will be ignored and the boundary will continue across the gap. When the input Room2Ds have floor_geometry representing the boundaries defined by the interior wall finishes, this input can be thought of as the maximum interior wall thickness, which should be ignored in the calculation of the overall boundary of the Room2Ds. When Room2Ds are touching one another (with Room2D floor_geometry drawn to the center lines of interior walls), this value can be set to zero or anything less than or equal to the tolerance. Doing so will yield a cleaner result for the boundary, which will be faster. Note that care should be taken not to set this value higher than the length of any meaningful exterior wall segments. Otherwise, the exterior segments will be ignored in the result. This can be particularly dangerous around curved exterior walls that have been planarized through subdivision into small segments. (Default: 0). tolerance: The maximum difference between coordinate values of two vertices at which they can be considered equivalent. (Default: 0.01, suitable for objects in meters). """ # get the floor geometry of the rooms floor_geos = [room.floor_geometry for room in room_2ds] # remove colinear vertices and degenerate rooms clean_floor_geos = [] for geo in floor_geos: try: clean_floor_geos.append(geo.remove_colinear_vertices(tolerance)) except AssertionError: # degenerate geometry to ignore pass if len(clean_floor_geos) == 0: return [] # no Room boundary to be found # convert the floor Face3Ds into counterclockwise Polygon2Ds floor_polys, z_vals = [], [] for flr_geo in clean_floor_geos: z_vals.append(flr_geo.min.z) b_poly = Polygon2D([Point2D(pt.x, pt.y) for pt in flr_geo.boundary]) floor_polys.append(b_poly) if flr_geo.has_holes: for hole in flr_geo.holes: h_poly = Polygon2D([Point2D(pt.x, pt.y) for pt in hole]) floor_polys.append(h_poly) z_min = min(z_vals) # if the min_separation is small, use the more reliable intersection method if min_separation <= tolerance: closed_polys = Polygon2D.joined_intersected_boundary(floor_polys, tolerance) else: # otherwise, use the more intense and less reliable gap crossing method closed_polys = Polygon2D.gap_crossing_boundary( floor_polys, min_separation, tolerance) # remove colinear vertices from the resulting polygons clean_polys = [] for poly in closed_polys: try: clean_polys.append(poly.remove_colinear_vertices(tolerance)) except AssertionError: pass # degenerate polygon to ignore # figure out if polygons represent holes in the others and make Face3D if len(clean_polys) == 0: return [] elif len(clean_polys) == 1: # can be represented with a single Face3D pts3d = [Point3D(pt.x, pt.y, z_min) for pt in clean_polys[0]] return [Face3D(pts3d)] else: # need to separate holes from distinct Face3Ds bound_faces = [] for poly in clean_polys: pts3d = tuple(Point3D(pt.x, pt.y, z_min) for pt in poly) bound_faces.append(Face3D(pts3d)) return Face3D.merge_faces_to_holes(bound_faces, tolerance)
[docs] @staticmethod def generate_alignment_axes(room_2ds, distance, direction=Vector2D(0, 1), angle_tolerance=1.0): """Get suggested LineSegment2Ds for the Room2D.align method. This method will return the most common axes across the input Room2D geometry along with the number of Room2D segments that correspond to each axis. The latter can be used to filter the suggested alignment axes to get only the most common ones across the input Room2Ds. Args: room_2ds: A list of Room2D objects for which common axes will be evaluated. distance: A number for the distance that will be used in the alignment operation. This will be used to determine the resolution at which alignment axes are generated and evaluated. Smaller alignment distances will result in the generation of more common_axes since a finer resolution can differentiate common that would typically be grouped together. For typical building geometry, an alignment distance of 0.3 meters or 1 foot is typically suitable for eliminating unwanted details while not changing the geometry too much from its original location. direction: A Vector2D object to represent the direction in which the common axes will be evaluated and generated. angle_tolerance: The max angle difference in degrees that the Room2D segment direction can differ from the input direction before the segments are not factored into this calculation of common axes. Returns: A tuple with two elements. - common_axes: A list of LineSegment2D objects for the common axes across the input Room2Ds. - axis_values: A list of integers that aligns with the common_axes and denotes how many segments of the input Room2D each axis relates to. Higher numbers indicate that that the axis is more commonly aligned across the Room2Ds. """ # process the inputs min_distance, merge_distance = distance / 3, distance ang_tol = math.radians(angle_tolerance) polygons = [] for room in room_2ds: polygons.append(room.floor_geometry.boundary_polygon2d) if room.floor_geometry.has_holes: for hole in room.floor_geometry.hole_polygon2d: polygons.append(hole) # return the common axes and values return Polygon2D.common_axes( polygons, direction, min_distance, merge_distance, ang_tol)
[docs] @staticmethod def floor_segment_by_index(geometry, segment_index): """Get a particular LineSegment3D from a Face3D object. The logic applied by this method to select the segment is the same that is used to assign lists of values to the floor_geometry (eg. boundary conditions). Args: geometry: A Face3D representing floor geometry. segment_index: An integer for the index of the segment to return. """ segs = geometry.boundary_segments if geometry.holes is \ None else geometry.boundary_segments + \ tuple(seg for hole in geometry.hole_segments for seg in hole) return segs[segment_index]
def _room_volume_with_roof(self, roof_spec, tolerance): """Get a Polyface3D for the Room volume given a roof_spec above the room. Args: roof_spec: A Dragonfly RoofSpecification that describes the Roof above the room geometry. tolerance: The minimum distance from roof polygon edges at which a point is considered to lie on the edge. Returns: A tuple with the two items below. * room_polyface -- A Polyface3D object for the Room volume. This will be None whenever the Room has no Roof geometries above it or the roof calculation otherwise failed. * roof_face_i -- A list of integers for the indices of the faces in the Polyface3D that correspond to the roof. Will be None whenever the roof is not successfully applied to the Room. """ # get the roof polygons and the bounding Room2D polygon roof_polys = roof_spec.boundary_geometry_2d roof_planes = roof_spec.planes room_pts2d = [Point2D(pt.x, pt.y) for pt in self.floor_geometry.boundary] room_poly = Polygon2D(room_pts2d) # gather all of the relevant roof polygons for the Room2D rel_rf_polys, rel_rf_planes, is_full_bound = [], [], False for rf_py, rf_pl in zip(roof_polys, roof_planes): poly_rel = rf_py.polygon_relationship(room_poly, tolerance) if poly_rel >= 0: rel_rf_polys.append(rf_py) rel_rf_planes.append(rf_pl) if poly_rel == 1: # simple solution of one roof is_full_bound = True rel_rf_polys = [rel_rf_polys[-1]] rel_rf_planes = [rel_rf_planes[-1]] break # make the room volume p_faces = [self.floor_geometry.flip()] # a list of Room volume faces proj_dir = Vector3D(0, 0, 1) # direction to project onto Roof planes # when fully bounded, simply project the segments onto the single Roof face if is_full_bound: roof_plane = rel_rf_planes[0] roof_verts = [] for seg in self.floor_segments: p1, p2 = seg.p1, seg.p2 p3 = roof_plane.project_point(p2, proj_dir) p4 = roof_plane.project_point(p1, proj_dir) p_faces.append(Face3D((p1, p2, p3, p4))) roof_verts.append(p4) if not self.floor_geometry.has_holes: p_faces.append(Face3D(roof_verts)) else: v_count = len(self.floor_geometry.boundary) part_roof_verts = [roof_verts[:v_count]] for hole in self.floor_geometry.holes: part_roof_verts.append(roof_verts[v_count:v_count + len(hole)]) v_count += len(hole) p_faces.append(Face3D(part_roof_verts[0], holes=part_roof_verts[1:])) return Polyface3D.from_faces(p_faces, tolerance), [-1] # when multiple roofs, each segment must be intersected with the roof polygons # gather polygons that account for all of the Room2D holes all_room_poly = [room_poly] flr_segs = self.floor_segments if self.floor_geometry.has_holes: v_count = len(room_poly) all_segments = [flr_segs[:v_count]] for hole in self.floor_geometry.holes: hole_poly = Polygon2D([Point2D(pt.x, pt.y) for pt in hole]) all_room_poly.append(hole_poly) all_segments.append(flr_segs[v_count:v_count + len(hole)]) v_count += len(hole) else: all_segments = [flr_segs] # get the roof faces using polygon boolean operations roof_faces = self._roof_faces( all_room_poly, rel_rf_polys, rel_rf_planes, tolerance) if roof_faces is None: # invalid roof geometry return None, None # create the walls from the segments by intersecting them with the roof if len(roof_faces) > len(rel_rf_polys): # new roofs added; rebuild polygons rel_rf_polys = [ Polygon2D(tuple(Point2D(pt.x, pt.y) for pt in geo.boundary)) for geo in roof_faces] rel_rf_planes = [geo.plane for geo in roof_faces] walls = self._wall_faces_with_roof( all_room_poly, all_segments, rel_rf_polys, rel_rf_planes, tolerance) if walls is None: # invalid roof geometry return None, None # combine all of the room volume faces together p_faces.extend(walls) roof_face_i = list(range(-1, -len(roof_faces) - 1, -1)) p_faces.extend(roof_faces) # create the Polyface3D and try to repair it if it is not solid room_polyface = Polyface3D.from_faces(p_faces, tolerance) ang_tol = math.radians(1) # make sure that overlapping edges are merged so we don't get false readings if not room_polyface.is_solid: room_polyface = room_polyface.merge_overlapping_edges(tolerance, ang_tol) # try to patch any vertical gaps between roofs with new walls if len(room_polyface.naked_edges) != 0: room_polyface, roof_face_i = \ self._patch_vertical_gaps(room_polyface, roof_face_i, tolerance) if not room_polyface.is_solid: room_polyface = room_polyface.merge_overlapping_edges(tolerance, ang_tol) # remove disconnected roof geometries from the Polyface (eg. dormers) if len(room_polyface.naked_edges) != 0: room_polyface, roof_face_i, _ = \ self._separate_disconnected_faces(room_polyface, roof_face_i, tolerance) if not room_polyface.is_solid: room_polyface = room_polyface.merge_overlapping_edges(tolerance, ang_tol) # lastly, try to patch any remaining planar holes by capping them if len(room_polyface.naked_edges) != 0: room_polyface, roof_face_i = \ self._cap_planar_holes(room_polyface, roof_face_i, tolerance) if not room_polyface.is_solid: room_polyface = room_polyface.merge_overlapping_edges(tolerance, ang_tol) # if we still have non-manifold edges, just remove the degenerate faces if len(room_polyface.non_manifold_edges) != 0: valid_faces = [] for face in room_polyface.faces: try: valid_faces.append(face.remove_colinear_vertices(tolerance)) except AssertionError: # degenerate face found! pass room_polyface = Polyface3D.from_faces(valid_faces, tolerance) if not room_polyface.is_solid: room_polyface = room_polyface.merge_overlapping_edges(tolerance, ang_tol) return room_polyface, roof_face_i def _roof_faces(self, all_room_poly, rel_rf_polys, rel_rf_planes, tolerance): """Generate Face3D for the Room Roofs when there are multiple Roof Polygons. Args: all_room_poly: A list of Polygon2D where each polygon represents either the boundary of the room or a hole. rel_rf_polys: A list of Polygon2D for the Roof geometries that are relevant to the Room2D. rel_rf_planes: A list of Plane objects for each Roof geometry that is relevant to the Room2D. tolerance: The distance value for absolute tolerance. Returns: A list of Face3D for the Roofs of the Room. Will be None if computing the roof geometry failed. """ roof_faces = [] proj_dir = Vector3D(0, 0, 1) # direction to project onto Roof planes # create a BooleanPolygon for the Room2D room_polys = [] for rom_poly in all_room_poly: try: rom_poly = rom_poly.remove_colinear_vertices(tolerance) except AssertionError: continue # degenerate polygon to ignore (usually degenerate hole) room_polys.append((pb.BooleanPoint(pt.x, pt.y) for pt in rom_poly.vertices)) if len(room_polys) == 0: # completely degenerate room return None b_room_poly = pb.BooleanPolygon(room_polys) room_poly_area = all_room_poly[0].area - sum(h.area for h in all_room_poly[1:]) # find the boolean intersection with each roof polygon and project the result int_tol = tolerance / 100 # intersection tolerance must be finer roof_poly_area = 0 for rf_poly, rf_plane in zip(rel_rf_polys, rel_rf_planes): # snap the polygons to one another to avoid tolerance issues try: rf_poly = rf_poly.remove_colinear_vertices(tolerance) except AssertionError: continue # degenerate roof polygon to ignore for rom_poly in all_room_poly: rf_poly = rom_poly.snap_to_polygon(rf_poly, tolerance) rf_pts = (pb.BooleanPoint(pt.x, pt.y) for pt in rf_poly.vertices) b_rf_poly = pb.BooleanPolygon([rf_pts]) try: int_result = pb.intersect(b_room_poly, b_rf_poly, int_tol) except Exception: # intersection failed for some reason return None polys = [Polygon2D(tuple(Point2D(pt.x, pt.y) for pt in new_poly)) for new_poly in int_result.regions] if self.floor_geometry.has_holes and len(polys) > 1: # sort the polygons by area and check if any are inside the others polys.sort(key=lambda x: x.area, reverse=True) poly_groups = [[polys[0]]] for sub_poly in polys[1:]: for i, pg in enumerate(poly_groups): if pg[0].is_polygon_inside(sub_poly): # it's a hole poly_groups[i].append(sub_poly) break else: # it's a separate Face3D poly_groups.append([sub_poly]) # convert all vertices to 3D and append the roof Face3D for pg in poly_groups: roof_poly_area += pg[0].area - sum(h.area for h in pg[1:]) pg_3d = [] for shp in pg: pt3s = tuple( rf_plane.project_point(Point3D.from_point2d(pt2), proj_dir) for pt2 in shp.vertices) pg_3d.append(pt3s) roof_faces.append(Face3D(pg_3d[0], rf_plane, holes=pg_3d[1:])) else: # no holes are possible in the result; project all polygons directly for sub_poly in polys: roof_poly_area += sub_poly.area pt3s = tuple( rf_plane.project_point(Point3D.from_point2d(pt2), proj_dir) for pt2 in sub_poly.vertices) roof_faces.append(Face3D(pt3s, rf_plane)) # if all of the polygons didn't cover the Room2D, add extra horizontal roof faces min_len = sorted(seg.length for seg in all_room_poly[0].segments)[0] min_len = tolerance if min_len < tolerance else min_len tol_area = min_len * tolerance area_diff = abs(room_poly_area - roof_poly_area) if abs(room_poly_area - roof_poly_area) > tol_area: # room not covered by roofs rm_z = self.ceiling_height subtract_geo = [] for rf_face in roof_faces: proj_pts = [Point3D(pt.x, pt.y, rm_z) for pt in rf_face.boundary] if rf_face.has_holes: hole_pts = [[Point3D(pt.x, pt.y, rm_z) for pt in hole] for hole in rf_face.holes] subtract_geo.append(Face3D(proj_pts, holes=hole_pts)) else: subtract_geo.append(Face3D(proj_pts)) ang_tol = math.radians(1) ceil_vec = Vector3D(0, 0, self.floor_to_ceiling_height) ceil_geo = self.floor_geometry.move(ceil_vec) cover_faces = ceil_geo.coplanar_difference(subtract_geo, tolerance, ang_tol) for f in cover_faces: if f.area <= area_diff + tol_area: roof_faces.append(f) return roof_faces def _wall_faces_with_roof(self, all_room_poly, all_segments, rel_rf_polys, rel_rf_planes, tolerance): """Generate Face3D for the Room Walls when there are multiple Roof Polygons. Args: all_room_poly: A list of Polygon2D where each polygon represents either the boundary of the room or a hole. all_segments: A list of lists where each sub-list contains LineSegment2D objects for each polygon in all_room_poly. rel_rf_polys: A list of Polygon2D for the Roof geometries that are relevant to the Room2D. rel_rf_planes: A list of Plane objects for each Roof geometry that is relevant to the Room2D. tolerance: The distance value for absolute tolerance. Returns: A list of Face3D for the Walls of the Room. Will be None if the Roof geometries are invalid. """ wall_faces = [] proj_dir = Vector3D(0, 0, 1) # direction to project onto Roof planes # loop through holes and boundary polygons and generate walls from them for rm_poly, rm_segs in zip(all_room_poly, all_segments): # find the polygon that the first room vertex is located in current_poly, current_plane = None, None other_poly, other_planes = rel_rf_polys[:], rel_rf_planes[:] # copy lists pt1 = rm_poly[0] for i, (rf_py, rf_pl) in enumerate(zip(rel_rf_polys, rel_rf_planes)): if rf_py.point_relationship(pt1, tolerance) >= 0: current_poly, current_plane = rf_py, rf_pl other_poly.pop(i) other_planes.pop(i) break if current_poly is None: # first point not inside a roof, invalid roof return None # loop through segments and add vertices if they cross outside the roof face rot_poly = rm_poly.vertices[1:] + (pt1,) for pt2, seg in zip(rot_poly, rm_segs): face_pts = [seg.p1, seg.p2] # see if the segment ends in the same face it starts in if current_poly.point_relationship(pt2, tolerance) >= 0: # project seg face_pts.append(current_plane.project_point(seg.p2, proj_dir)) face_pts.append(current_plane.project_point(seg.p1, proj_dir)) else: int_pts, int_pls = [(seg.p1, 0)], [current_plane] # find where the segment leaves the polygon seg_2d = LineSegment2D.from_array(((pt1.x, pt1.y), (pt2.x, pt2.y))) for rf_seg in current_poly.segments: int_pt = seg_2d.intersect_line_ray(rf_seg) if int_pt is None: dist, cls_pts = closest_point2d_between_line2d( seg_2d, rf_seg) if dist <= tolerance: int_pt = cls_pts[0] if int_pt is not None: int_pts.append((int_pt, 0)) int_pls.append(current_plane) # find where it intersects the other relevant polygons for o_poly, o_pl in zip(other_poly, other_planes): for o_seg in o_poly.segments: int_pt = seg_2d.intersect_line_ray(o_seg) if int_pt is None: d, cls_pts = closest_point2d_between_line2d( seg_2d, o_seg) if d <= tolerance: int_pt = cls_pts[0] if int_pt is not None: int_pts.append((int_pt, 1)) int_pls.append(o_pl) # sort the intersections points along the segment pt_dists = [(ipt[1], seg_2d.p1.distance_to_point(ipt[0])) for ipt in int_pts] pts_pls = [ ( i_pt[0], i_pl, i_pl.project_point(Point3D.from_point2d(i_pt[0]), proj_dir) ) for i_pt, i_pl in zip(int_pts, int_pls)] sort_obj = sorted(zip(pt_dists, pts_pls), key=lambda pair: pair[0]) sort_pts_pls = [x for _, x in sort_obj] # remove any point/plane combinations that are duplicates i_to_remove = [] for i, (pt, pln, pt3) in enumerate(sort_pts_pls[1:]): if i == 0: # first vertex is always correct so keep it continue if pt3.distance_to_point(sort_pts_pls[i][2]) < tolerance: i_to_remove.append(i) for del_i in reversed(i_to_remove): sort_pts_pls.pop(del_i) # if two points are equivalent, reorder with the previous point plane ord_pts = [x[0] for x in sort_pts_pls] ord_pls = [x[1] for x in sort_pts_pls] ord_pts3 = [x[2] for x in sort_pts_pls] for i, (pt, pln, pt3) in enumerate(sort_pts_pls[1:]): if i == 0: continue if pt.distance_to_point(ord_pts[i]) < tolerance: prev_pl = ord_pls[i - 1] if prev_pl.distance_to_point(pt3) < \ prev_pl.distance_to_point(sort_pts_pls[i][2]): # reorder the points ord_pts[i], ord_pts[i + 1] = ord_pts[i + 1], ord_pts[i] ord_pls[i], ord_pls[i + 1] = ord_pls[i + 1], ord_pls[i] ord_pts3[i], ord_pts3[i + 1] = ord_pts3[i + 1], ord_pts3[i] # project the points onto the planes rf_pts = [ipl.project_point(Point3D.from_point2d(ipt), proj_dir) for ipt, ipl in zip(ord_pts, ord_pls)] # add a vertex for where the segment ends in the polygon for i, (rf_py, rf_pl) in enumerate(zip(other_poly, other_planes)): if rf_py.point_relationship(pt2, tolerance) >= 0: other_poly.pop(i) other_poly.append(current_poly) other_planes.pop(i) other_planes.append(current_plane) current_poly, current_plane = rf_py, rf_pl rf_pts.append( rf_pl.project_point(Point3D.from_point2d(pt2), proj_dir)) break # remove duplicated vertices from the list rf_pts = [pt for i, pt in enumerate(rf_pts) if not pt.is_equivalent(rf_pts[i - 1], tolerance)] if current_poly is None or len(rf_pts) < 2: return None # point not inside a roof; invalid roof # check that the first two vertices are not a sliver if abs(rf_pts[0].x - rf_pts[1].x) < tolerance and \ abs(rf_pts[0].y - rf_pts[1].y) < tolerance: rf_pts.pop(0) # add the points to the Face3D vertices rf_pts.reverse() face_pts.extend(rf_pts) # make the final Face3D if len(face_pts) == 2: # second point not inside a roof, invalid roof return None wall_faces.append(Face3D(face_pts)) pt1 = pt2 # increment for next segment return wall_faces def _patch_vertical_gaps(self, room_polyface, roof_face_i, tolerance): """Patch any vertical gaps in a room_polyface. This method should fill all cases of vertical gaps within a Polyface3D. The only exception is if the vertical gap happens between two edges that overlap in plan but they share no end points. To catch this particular type of edge case, the _cap_planar_holes method should be used. Args: room_polyface: The non-solid Polyface3D to be patched with planar vertical Faces. roof_face_i: The indices of the polyface that correspond to the roof. tolerance: The distance value for absolute tolerance. Returns: The patched Room Polyface3D followed by an updated list of face indices that should become Roofs. """ # get the faces and naked edges p_faces = list(room_polyface.faces) edges = [ed for ed in room_polyface.naked_edges if not ed.is_vertical(tolerance)] vertical_faces = [] # loop through the naked edges and try to match them matched_segs = set() edge_indices = list(range(len(edges))) for i, edge_1 in enumerate(edges): edge_1_2d = LineSegment2D.from_end_points( Point2D(edge_1.p1.x, edge_1.p1.y), Point2D(edge_1.p2.x, edge_1.p2.y)) other_edges = edges[:i] + edges[i + 1:] other_is = edge_indices[:i] + edge_indices[i + 1:] for oi, edge_2 in zip(other_is, other_edges): e2p1 = Point2D(edge_2.p1.x, edge_2.p1.y) e2p2 = Point2D(edge_2.p2.x, edge_2.p2.y) if edge_1_2d.distance_to_point(e2p1) <= tolerance and \ edge_1_2d.distance_to_point(e2p2) <= tolerance: # check to be sure that the segments have not been aired already edge_pair_1, edge_pair_2 = (i, oi), (oi, i) if edge_pair_1 in matched_segs: continue matched_segs.add(edge_pair_1) matched_segs.add(edge_pair_2) # build the points of the vertical face norm = Vector3D(edge_1.v.x, edge_1.v.y, 0) int_pl_1 = Plane(n=norm, o=edge_2.p1) int_pl_2 = Plane(n=norm, o=edge_2.p2) edge_1_1 = intersect_line3d_plane_infinite(edge_1, int_pl_1) edge_1_2 = intersect_line3d_plane_infinite(edge_1, int_pl_2) new_face3d = Face3D((edge_1_1, edge_1_2, edge_2.p1, edge_2.p2)) # find the grouping of points that is not self intersecting if not new_face3d.is_self_intersecting and \ new_face3d.area > tolerance: vertical_faces.append(new_face3d) else: new_face3d = Face3D((edge_1_1, edge_1_2, edge_2.p2, edge_2.p1)) if not new_face3d.is_self_intersecting and \ new_face3d.area > tolerance: vertical_faces.append(new_face3d) else: f_poly = new_face3d.polygon2d fs1, fs2 = f_poly.segments[0], f_poly.segments[2] int_pt2d = fs1.intersect_line_ray(fs2) if int_pt2d is not None: int_pt = new_face3d.plane.xy_to_xyz(int_pt2d) new_face3d1 = Face3D((edge_2.p1, int_pt, edge_1_1)) new_face3d2 = Face3D((edge_2.p2, int_pt, edge_1_2)) if new_face3d1.area > tolerance: vertical_faces.append(new_face3d1) if new_face3d2.area > tolerance: vertical_faces.append(new_face3d2) # remove duplicated vertices in the resulting vertical faces clean_vert_faces = [] for f in vertical_faces: try: clean_vert_faces.append(f.remove_duplicate_vertices(tolerance)) except AssertionError: pass # invalid sliver face # rebuild the room polyface st_v = -len(clean_vert_faces) - 1 roof_face_i = list(range(st_v, st_v - len(roof_face_i), -1)) p_faces.extend(clean_vert_faces) room_polyface = Polyface3D.from_faces(p_faces, tolerance) return room_polyface, roof_face_i def _cap_planar_holes(self, room_polyface, roof_face_i, tolerance): """Cap all planar holes in a room_polyface. Args: room_polyface: The non-solid Polyface3D to be patched with planar vertical Faces. roof_face_i: The indices of the polyface that correspond to the roof. tolerance: The distance value for absolute tolerance. Returns: The capped Room Polyface3D followed by an updated list of face indices that should become Roofs. """ # join all of the naked edges into closed loops naked_edges = room_polyface.naked_edges if len(naked_edges) == 0: return room_polyface, roof_face_i joined_loops = Polyline3D.join_segments(naked_edges, tolerance) # create Face3D from any closed planar loops cap_faces = [] for loop in joined_loops: if isinstance(loop, Polyline3D) and loop.is_closed(tolerance): cap_face = Face3D(loop.vertices[:-1]) if cap_face.check_planar(tolerance, raise_exception=False): cap_faces.append(cap_face) # remove duplicated vertices in the resulting cap faces clean_cap_faces = [] for f in cap_faces: try: clean_cap_faces.append(f.remove_duplicate_vertices(tolerance)) except AssertionError: pass # invalid sliver face if len(clean_cap_faces) == 0: return room_polyface, roof_face_i # rebuild the room polyface st_v = -len(clean_cap_faces) + roof_face_i[0] roof_face_i = list(range(st_v, st_v - len(roof_face_i), -1)) p_faces = list(room_polyface.faces) + clean_cap_faces room_polyface = Polyface3D.from_faces(p_faces, tolerance) return room_polyface, roof_face_i def _separate_disconnected_faces(self, room_polyface, roof_face_i, tolerance): """Separate Face3Ds from a room_polyface, with are not connected to the solid. This will also remove all degenerate faces from the Polyface3D geometry. Args: room_polyface: The non-solid Polyface3D for which disconnected faces will be separated out. roof_face_i: The indices of the polyface that correspond to the roof. tolerance: The distance value for absolute tolerance. Returns: A tuple with three elements. * room_polyface -- The new Room Polyface3D. * roof_face_i -- An updated list of roof face indices in the polyface. * disconnect_geometry -- A list of Face3D objects, which are disconnected and were removed from the Polyface3D. """ # remove disconnected roof geometries from the Polyface (eg. dormers) disconnect_geometry, room_ind, disconnect_i = [], [], [] edge_i, edge_t = room_polyface.edge_indices, room_polyface.edge_types zip_obj = zip(room_polyface.face_indices, room_polyface.faces) for f_ind, (face, f3d) in enumerate(zip_obj): fe_types = [] for fi in face: for i, vi in enumerate(fi): try: ind = edge_i.index((vi, fi[i - 1])) et = edge_t[ind] except ValueError: # make sure reversed edge isn't there try: ind = edge_i.index((fi[i - 1], vi)) et = edge_t[ind] except ValueError: # an edge that was merged in overlapping et = 1 fe_types.append(et) if sum(fe_types) <= 1: # disconnected face found! disconnect_i.append(f_ind) else: try: f3d.remove_duplicate_vertices(tolerance) room_ind.append(f_ind) except AssertionError: # degenerate sliver face to be removed disconnect_i.append(f_ind) if len(disconnect_i) != 0: # process the roof indices new_roof_face_i = [] for exist_i in roof_face_i: pos_ei = exist_i + len(room_polyface) for del_i in reversed(disconnect_i): if del_i == pos_ei: # deleted roof break else: new_roof_face_i.append(exist_i) roof_face_i = new_roof_face_i # rebuild the Polyface3D p_faces = [room_polyface.faces[f_ind] for f_ind in room_ind] disconnect_geometry = [room_polyface.faces[f_ind] for f_ind in disconnect_i] room_polyface = Polyface3D.from_faces(p_faces, tolerance) return room_polyface, roof_face_i, disconnect_geometry def _honeybee_plenums(self, hb_room, tolerance=0.01): """Get ceiling and/or floor plenums for the Room2D as a Honeybee Room. This method will check if there is a gap between the Room2D's ceiling and floor, and the top and bottom of it's corresponding Story, respectively. If there is a gap along the z axis larger then the specified tolerance, it will compute the necessary ceiling and/or floor plenum to fill the gap. Args: hb_room: A honeybee Room representing the dragonfly Room2D. tolerance: The minimum distance in z values to check if the Room ceiling and floor is adjacent to the upper and lower floor of the Story, respectively. If not adjacent, the corresponding ceiling or floor plenum is generated. Default: 0.01, suitable for objects in meters. Returns: A list of Honeybee Rooms with two items: * ceil_plenum -- A honeybee-core Room representing the ceiling plenum. If there isn't enough space between the Story floor_to_floor_height and the Room2D floor_to_ceiling height, this item will be None. * floor_plenum -- A honeybee-core Room representing the floor plenum. If there isn't enough space between the Story floor_height and the Room2D floor_height, this item will be None. """ # check to be sure that the room2d has a parent story hb_rooms = [] if not self.has_parent: raise AttributeError( 'Cannot add plenums to the "{}" Room because the parent Story has ' 'not been set. This is required to derive the plenum ' 'height.'.format(self.identifier)) parent = self.parent parent_ceiling = parent.floor_height + parent.floor_to_floor_height ceil_plenum_height = parent_ceiling - self.ceiling_height floor_plenum_height = self.floor_height - parent.floor_height if ceil_plenum_height > tolerance: ceil_plenum = self._honeybee_plenum( ceil_plenum_height, plenum_type="ceiling") # Set the plenum and the rooms to be adjacent to one another hb_room[-1].set_adjacency(ceil_plenum[0], tolerance) hb_rooms.append(ceil_plenum) if floor_plenum_height > tolerance: floor_plenum = self._honeybee_plenum( floor_plenum_height, plenum_type="floor") # Set the plenum and the rooms to be adjacent to one another hb_room[0].set_adjacency(floor_plenum[-1], tolerance) try: hb_room[0].boundary_condition = bcs.adiabatic except AttributeError: pass hb_rooms.append(floor_plenum) return hb_rooms def _honeybee_plenum(self, plenum_height, plenum_type='ceiling'): """Get a ceiling or floor plenum for the Room2D as a Honeybee Room. The boundary condition for all plenum faces is adiabatic except for the ceiling and floor surfaces between the room, and any outdoor walls. Args: hb_room: A honeybee Room representing the dragonfly Room2D. plenum_height: The height of the plenum Room. plenum_type: Text for the type of plenum to be constructed. Choose from the following: * ceiling * floor Returns: A honeybee Room representing a plenum zone. """ plenum_id = self.identifier + '_{}_plenum'.format(plenum_type) # create reference 2d geometry for plenums ref_face3d = self.floor_geometry.duplicate() if plenum_type == 'ceiling': ref_face3d = ref_face3d.move(Vector3D(0, 0, self.floor_to_ceiling_height)) else: ref_face3d = ref_face3d.move(Vector3D(0, 0, -plenum_height)) # create the honeybee Room plenum_hb_room = Room.from_polyface3d( plenum_id, Polyface3D.from_offset_face(ref_face3d, plenum_height)) # get the boundary condition that will be used for interior surfaces try: interior_bc = bcs.adiabatic except AttributeError: # honeybee_energy is not loaded; no Adiabatic BC interior_bc = bcs.outdoors # assign wall BCs based on self for i, bc in enumerate(self._boundary_conditions): if not isinstance(bc, Surface): plenum_hb_room[i + 1].boundary_condition = bc else: # assign boundary conditions for the roof and floor plenum_hb_room[i + 1].boundary_condition = interior_bc if plenum_type == 'ceiling': # assign ceiling BCs if self._is_top_exposed: plenum_hb_room[-1].boundary_condition = bcs.outdoors else: plenum_hb_room[-1].boundary_condition = interior_bc else: # assign floor BCss if self._is_ground_contact: plenum_hb_room[0].boundary_condition = bcs.ground else: plenum_hb_room[0].boundary_condition = interior_bc return plenum_hb_room def _check_wall_assigned_object(self, value, obj_name=''): """Check an input that gets assigned to all of the walls of the Room.""" try: value = list(value) if not isinstance(value, list) else value except (ValueError, TypeError): raise TypeError('Input {} must be a list or a tuple'.format(obj_name)) assert len(value) == len(self), 'Input {} length must be the ' \ 'same as the number of floor_segments. {} != {}'.format( obj_name, len(value), len(self)) return value @staticmethod def _flip_wall_assigned_objects(original_geo, bcs, win_pars, shd_pars): """Get arrays of wall-assigned parameters that are flipped/reversed. This method accounts for the case that a floor geometry has holes in it. """ # go through the boundary and ensure detailed parameters are flipped new_bcs = [] new_win_pars = [] new_shd_pars = [] for i, seg in enumerate(original_geo.boundary_segments): new_bcs.append(bcs[i]) win_par = win_pars[i] if isinstance(win_par, _AsymmetricBase): new_win_pars.append(win_par.flip(seg.length)) else: new_win_pars.append(win_par) new_shd_pars.append(shd_pars[i]) # reverse the lists of wall-assigned objects on the floor boundary new_bcs.reverse() new_win_pars.reverse() new_shd_pars.reverse() # add any objects related to the holes if original_geo.has_holes: bound_len = len(original_geo.boundary) new_bcs = new_bcs + bcs[bound_len:] new_win_pars = new_win_pars + win_pars[bound_len:] new_shd_pars = new_shd_pars + shd_pars[bound_len:] # return the flipped lists return new_bcs, new_win_pars, new_shd_pars def _split_walls_along_height(self, hb_room, tolerance, plenums=False): """Split adjacent walls to ensure matching surface areas in to_honeybee workflow. Args: hb_room: A non-split Honeybee Room representation of this Room2D. tolerance: The minimum distance in z values of floor_height and floor_to_ceiling_height at which adjacent Faces will be split. plenums: A boolean to note whether the resulting model has auto-generated plenums, which will determine the default boundary condition of any split wall segments. (Default: False). """ new_faces = [hb_room[0]] for i, bc in enumerate(self._boundary_conditions): face = hb_room[i + 1] if not isinstance(bc, Surface): new_faces.append(face) else: try: adj_rm = self._parent.room_by_identifier( bc.boundary_condition_objects[-1]) except ValueError: # missing adjacency in Story; just pass invalid BC new_faces.append(face) continue flr_diff = adj_rm.floor_height - self.floor_height ciel_diff = self.ceiling_height - adj_rm.ceiling_height if flr_diff <= tolerance and ciel_diff <= tolerance: # No need to split the surface along its height new_faces.append(face) elif flr_diff > tolerance and ciel_diff > tolerance: # split the face into to 3 smaller faces along its height lseg = LineSegment3D.from_end_points(face.geometry[0], face.geometry[1]) mid_dist = self.floor_to_ceiling_height - ciel_diff - flr_diff vec1 = Vector3D(0, 0, flr_diff) vec2 = Vector3D(0, 0, self.floor_to_ceiling_height - ciel_diff) below = Face3D.from_extrusion(lseg, vec1) mid = Face3D.from_extrusion( lseg.move(vec1), Vector3D(0, 0, mid_dist)) above = Face3D.from_extrusion( lseg.move(vec2), Vector3D(0, 0, ciel_diff)) mid_face = face.duplicate() mid_face._geometry = mid self._reassign_split_windows(mid_face, i, tolerance) below_face = Face('{}_Below'.format(face.identifier), below) above_face = Face('{}_Above'.format(face.identifier), above) try: below_face.boundary_condition = bcs.ground \ if self.is_ground_contact and not plenums else bcs.adiabatic except AttributeError: pass # honeybee_energy is not loaded; no adiabatic BC try: below_face.boundary_condition = bcs.outdoors \ if adj_rm.is_top_exposed and not plenums else bcs.adiabatic except AttributeError: pass # honeybee_energy is not loaded; no adiabatic BC new_faces.extend([below_face, mid_face, above_face]) elif flr_diff > tolerance: # split the face into to 2 smaller faces along its height lseg = LineSegment3D.from_end_points(face.geometry[0], face.geometry[1]) mid_dist = self.floor_to_ceiling_height - flr_diff vec1 = Vector3D(0, 0, flr_diff) below = Face3D.from_extrusion(lseg, vec1) mid = Face3D.from_extrusion( lseg.move(vec1), Vector3D(0, 0, mid_dist)) mid_face = face.duplicate() mid_face._geometry = mid self._reassign_split_windows(mid_face, i, tolerance) below_face = Face('{}_Below'.format(face.identifier), below) try: below_face.boundary_condition = bcs.ground \ if self.is_ground_contact and not plenums else bcs.adiabatic except AttributeError: pass # honeybee_energy is not loaded; no adiabatic BC new_faces.extend([below_face, mid_face]) elif ciel_diff > tolerance: # split the face into to 2 smaller faces along its height lseg = LineSegment3D.from_end_points(face.geometry[0], face.geometry[1]) mid_dist = self.floor_to_ceiling_height - ciel_diff vec1 = Vector3D(0, 0, mid_dist) mid = Face3D.from_extrusion(lseg, vec1) above = Face3D.from_extrusion( lseg.move(vec1), Vector3D(0, 0, ciel_diff)) mid_face = face.duplicate() mid_face._geometry = mid self._reassign_split_windows(mid_face, i, tolerance) above_face = Face('{}_Above'.format(face.identifier), above) try: above_face.boundary_condition = bcs.outdoors \ if adj_rm.is_top_exposed and not plenums else bcs.adiabatic except AttributeError: pass # honeybee_energy is not loaded; no adiabatic BC new_faces.extend([mid_face, above_face]) new_faces.append(hb_room[-1]) return new_faces def _reassign_split_windows(self, face, i, tolerance): """Re-assign WindowParameters to any base surface that has been split. Args: face: Honeybee Face to which windows will be re-assigned. i: The index of the window_parameters that correspond to the face tolerance: The tolerance, which will be used to re-assign windows. """ glz_par = self._window_parameters[i] if glz_par is not None: face.remove_sub_faces() glz_par.add_window_to_face(face, tolerance) @staticmethod def _segment_wall_face(room, segment, tolerance): """Get a Wall Face that corresponds with a certain wall segment. Args: room: A Honeybee Room from which a wall Face will be returned. segment: A LineSegment3D along one of the walls of the room. tolerance: The maximum difference between values at which point vertices are considered to be the same. """ for face in room.faces: if isinstance(face.type, (Wall, AirBoundary)): fg = face.geometry try: verts = fg._remove_colinear( fg._boundary, fg.boundary_polygon2d, tolerance) except AssertionError: return None for v1 in verts: if segment.p1.is_equivalent(v1, tolerance): p2 = segment.p2 for v2 in verts: if p2.is_equivalent(v2, tolerance): return face def _match_and_transfer_wall_props(self, new_room, tolerance, transfer_air_bounds=False): """Transfer wall properties of matching segments between this room and a new one. All wall properties are transferred exactly as they are when segments are perfectly equal between this room and the new room. When segments are colinear/overlapping but the segment on the new_room is shorter than that on this room, the wall properties on this room will be split in order to assign them correctly to the new room. When a given segment of the new_room is not overlapping/colinear with any segment of this room, it will be given default properties with an outdoor boundary condition. This all makes this method suitable for preserving properties across operations that trim or split the original room to make the new_room. Args: new_room: An new Room2D to which wall properties will be transferred. tolerance: The minimum distance at which points are considered distinct. transfer_air_bounds: Boolean for whether the air boundary properties should be transferred. (Default: False). """ # get the relevant original segments by copying the lists on this Room2D rel_segs = self.floor_segments rel_win = self._window_parameters rel_shd = self._shading_parameters rel_abs = self.air_boundaries rel_bcs = [] for bc in self._boundary_conditions: if not isinstance(bc, Surface): rel_bcs.append(bc) else: # Surface boundary conditions can mess up window splitting rel_bcs.append(bcs.outdoors) # build up new lists of parameters if the segments match new_bcs, new_win, new_shd, new_abs = {}, {}, {}, {} for k, seg1 in enumerate(rel_segs): m_win_segs, m_i = [], [] for i, seg2 in enumerate(new_room.floor_segments): if seg1.distance_to_point(seg2.p1) <= tolerance and \ seg1.distance_to_point(seg2.p2) <= tolerance: # colinear new_bcs[i] = rel_bcs[k] new_shd[i] = rel_shd[k] new_abs[i] = rel_abs[k] m_win_segs.append(seg2) m_i.append(i) # split the window parameters across the matched segments wp_par_to_split = rel_win[k] if wp_par_to_split is None: for i in m_i: new_win[i] = None else: full_len = sum(sg.length for sg in m_win_segs) if abs(seg1.length - full_len) <= tolerance: # all segments accounted if len(m_i) == 1: # no change to the segment new_win[m_i[0]] = wp_par_to_split else: # windows to be split split_par = wp_par_to_split.split(m_win_segs, tolerance) for i, w_par in zip(m_i, split_par): new_win[i] = w_par else: # not all segment accounted; trim each window par from original for i, n_seg in zip(m_i, m_win_segs): new_win[i] = wp_par_to_split.trim(seg1, n_seg, tolerance) # assign the matched properties to the new room final_bcs, final_win, final_shd, final_abs = [], [], [], [] for i in range(len(new_room)): try: final_bcs.append(new_bcs[i]) final_win.append(new_win[i]) final_shd.append(new_shd[i]) final_abs.append(new_abs[i]) except KeyError: # segment not matched to any in existing room final_bcs.append(bcs.outdoors) final_win.append(None) final_shd.append(None) final_abs.append(False) new_room.boundary_conditions = final_bcs new_room.window_parameters = final_win new_room.shading_parameters = final_shd if transfer_air_bounds: new_room.air_boundaries = final_abs @staticmethod def _remove_colinear_props( pts_3d, pts_2d, segs_2d, bound_cds, win_pars, ftc_height, tolerance): """Remove colinear vertices across a boundary while merging window properties.""" new_vertices, new_bcs, new_w_par = [], [], [] skip = 0 # track the number of vertices being skipped/removed m_segs, m_bcs, m_w_par = [], [], [] # loop through vertices and remove all cases of colinear verts for i, _v in enumerate(pts_2d): m_segs.append(segs_2d[i - 2]) m_bcs.append(bound_cds[i - 2]) m_w_par.append(win_pars[i - 2]) _a = pts_2d[i - 2 - skip].determinant(pts_2d[i - 1]) + \ pts_2d[i - 1].determinant(_v) + _v.determinant(pts_2d[i - 2 - skip]) if abs(_a) >= tolerance: # vertex is not colinear; add vertex and merge new_vertices.append(pts_3d[i - 1]) if all(not isinstance(bc, Ground) for bc in m_bcs): new_bcs.append(bcs.outdoors) if all(wp is None for wp in m_w_par): new_w_par.append(None) elif len(m_w_par) == 1: new_w_par.append(m_w_par[0]) else: new_wp = DetailedWindows.merge(m_w_par, m_segs, ftc_height) new_w_par.append(new_wp) else: new_bcs.append(bcs.ground) new_w_par.append(None) skip = 0 m_bcs, m_w_par, m_segs = [], [], [] else: # vertex is colinear; continue skip += 1 # catch case of last two vertices being equal but distinct from first point if skip != 0 and pts_3d[-2].is_equivalent(pts_3d[-1], tolerance): _a = pts_2d[-3].determinant(pts_2d[-1]) + \ pts_2d[-1].determinant(pts_2d[0]) + pts_2d[0].determinant(pts_2d[-3]) if abs(_a) >= tolerance: new_vertices.append(pts_3d[-1]) if not isinstance(bound_cds[-2], Ground): new_bcs.append(bcs.outdoors) new_w_par.append(win_pars[-2]) else: new_bcs.append(bcs.ground) new_w_par.append(None) elif skip != 0: w_par_for_merge = m_w_par + [new_w_par[0]] if not all(wp is None for wp in w_par_for_merge): segs_for_merge = m_segs + [segs_2d[-1]] new_w_par[0] = DetailedWindows.merge( w_par_for_merge, segs_for_merge, ftc_height) # move the first properties to the end to match with the vertices new_bcs.append(new_bcs.pop(0)) new_w_par.append(new_w_par.pop(0)) return new_vertices, new_bcs, new_w_par @staticmethod def _adjacency_grouping(rooms, adj_finding_function): """Group Room2Ds together according to an adjacency finding function. Args: rooms: A list of Room2Ds to be grouped by their adjacency. adj_finding_function: A function that denotes which rooms are adjacent to another. Returns: A list of list with each sub-list containing rooms that share adjacencies. """ # create a room lookup table and duplicate the list of rooms room_lookup = {rm.identifier: rm for rm in rooms} all_rooms = list(rooms) adj_network = [] # loop through the rooms and find air boundary adjacencies for room in all_rooms: adj_ids = adj_finding_function(room) if len(adj_ids) == 0: # a room that is its own solar enclosure adj_network.append([room]) else: # there are other adjacent rooms to find local_network = [room] local_ids, first_id = set(adj_ids), room.identifier while len(adj_ids) != 0: # add the current rooms to the local network adj_objs = [] for rm_id in adj_ids: try: adj_objs.append(room_lookup[rm_id]) except KeyError: pass # not a Room2D that is in the input local_network.extend(adj_objs) adj_ids = [] # reset the list of new adjacencies # find any rooms that are adjacent to the adjacent rooms for obj in adj_objs: all_new_ids = adj_finding_function(obj) new_ids = [rid for rid in all_new_ids if rid not in local_ids and rid != first_id] for rm_id in new_ids: local_ids.add(rm_id) adj_ids.extend(new_ids) # after the local network is understood, clean up duplicated rooms adj_network.append(local_network) i_to_remove = [i for i, room_obj in enumerate(all_rooms) if room_obj.identifier in local_ids] for i in reversed(i_to_remove): all_rooms.pop(i) return adj_network @staticmethod def _find_adjacent_rooms(room): """Find the identifiers of all rooms with adjacency to a room.""" adj_rooms = [] for bc in room._boundary_conditions: if isinstance(bc, Surface): adj_rooms.append(bc.boundary_condition_objects[-1]) return adj_rooms @staticmethod def _find_adjacent_air_boundary_rooms(room): """Find the identifiers of all rooms with air boundary adjacency to a room.""" adj_rooms = [] for bc, ab in zip(room._boundary_conditions, room.air_boundaries): if ab and isinstance(bc, Surface): adj_rooms.append(bc.boundary_condition_objects[-1]) return adj_rooms @staticmethod def _segments_along_polygon( polygon, rel_segs, rel_bcs, rel_win, rel_shd, rel_abs, new_bcs, new_win, new_shd, new_abs, new_flr_height, tol): """Find the segments along a polygon and add their properties to new lists.""" new_segs = [] for seg in polygon.segments: seg_segs, seg_bcs, seg_win, seg_shd, seg_abs = [], [], [], [], [] # collect the room segments and properties along the boundary for i, rs in enumerate(rel_segs): if seg.distance_to_point(rs.p1) <= tol and \ seg.distance_to_point(rs.p2) <= tol: # colinear seg_segs.append(rs) seg_bcs.append(rel_bcs[i]) seg_win.append(rel_win[i]) seg_shd.append(rel_shd[i]) seg_abs.append(rel_abs[i]) if len(seg_segs) == 0: Room2D._add_dummy_segment( seg.p1, seg.p2, new_segs, new_bcs, new_win, new_shd, new_abs) continue # sort the Room2D segments along the polygon segment seg_dists = [seg.p1.distance_to_point(s.p1) for s in seg_segs] sort_ind = [i for _, i in sorted(zip(seg_dists, range(len(seg_dists))))] seg_segs = [seg_segs[i] for i in sort_ind] seg_bcs = [seg_bcs[i] for i in sort_ind] seg_win = [seg_win[i] for i in sort_ind] seg_shd = [seg_shd[i] for i in sort_ind] seg_abs = [seg_abs[i] for i in sort_ind] # identify any gaps and add dummy segments p1_dists = sorted(seg_dists) p2_dists = [seg.p1.distance_to_point(s.p2) for s in seg_segs] last_d, last_seg = 0, None for i, (p1d, p2d) in enumerate(zip(p1_dists, p2_dists)): if p1d < last_d - tol: # overlapping segment; ignore it continue elif p1d > last_d + tol: # add a dummy segment for the gap st_pt = last_seg.p2 if last_seg is not None else seg.p1 Room2D._add_dummy_segment( st_pt, seg_segs[i].p1, new_segs, new_bcs, new_win, new_shd, new_abs) # add the segment new_segs.append(seg_segs[i]) new_bcs.append(seg_bcs[i]) new_win.append(seg_win[i]) new_shd.append(seg_shd[i]) new_abs.append(seg_abs[i]) last_d = p2d last_seg = seg_segs[i] return [Point3D(s.p1.x, s.p1.y, new_flr_height) for s in new_segs] @staticmethod def _add_dummy_segment(p1, p2, new_segs, new_bcs, new_win, new_shd, new_abs): """Add a dummy segment to lists of properties that are being built.""" new_segs.append(LineSegment2D.from_end_points(p1, p2)) new_bcs.append(bcs.outdoors) new_win.append(None) new_shd.append(None) new_abs.append(False) @staticmethod def _seg_on_guide_lines(segment, guide_lines, tolerance=0.01): """Evaluate whether a segment lies along a sed of guide lines. Args: segment: A LineSegment2D to be evaluated. guide_lines: A list of LineSegment2D objects for guide segments. tolerance: The minimum difference between the coordinate values of two faces at which they can be considered toughing. (Default: 0.01, suitable for objects in meters). """ pt1, pt2 = segment.p1, segment.p2 for g_line in guide_lines: if g_line.distance_to_point(pt1) <= tolerance and \ g_line.distance_to_point(pt2) <= tolerance: return True return False @staticmethod def _intersect_line2d_infinite(line_ray_a, line_ray_b): """Get the intersection between a Ray2Ds extended infinitely. Args: line_ray_a: A Ray2D object. line_ray_b: Another Ray2D object. Returns: Point2D of intersection if it exists. None if lines are parallel. """ d = line_ray_b.v.y * line_ray_a.v.x - line_ray_b.v.x * line_ray_a.v.y if d == 0: return None dy = line_ray_a.p.y - line_ray_b.p.y dx = line_ray_a.p.x - line_ray_b.p.x ua = (line_ray_b.v.x * dy - line_ray_b.v.y * dx) / d return Point2D(line_ray_a.p.x + ua * line_ray_a.v.x, line_ray_a.p.y + ua * line_ray_a.v.y) def __copy__(self): new_r = Room2D(self.identifier, self._floor_geometry, self.floor_to_ceiling_height, self._boundary_conditions[:]) # copy boundary condition list new_r._display_name = self._display_name new_r._user_data = None if self.user_data is None else self.user_data.copy() new_r._parent = self._parent new_wp = [] for wp in self._window_parameters: nwp = wp.duplicate() if wp is not None else None new_wp.append(nwp) new_r._window_parameters = new_wp new_r._shading_parameters = self._shading_parameters[:] # copy shading list new_r._air_boundaries = self._air_boundaries[:] \ if self._air_boundaries is not None else None new_r._is_ground_contact = self._is_ground_contact new_r._is_top_exposed = self._is_top_exposed new_r._has_floor = self._has_floor new_r._has_ceiling = self._has_ceiling new_r._skylight_parameters = self._skylight_parameters.duplicate() \ if self._skylight_parameters is not None else None new_r._abridged_properties = self._abridged_properties new_r._properties._duplicate_extension_attr(self._properties) return new_r def __len__(self): return self._segment_count def __getitem__(self, key): return self.floor_segments[key] def __iter__(self): return iter(self.floor_segments) def __repr__(self): return 'Room2D: %s' % self.display_name