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

from ladybug_geometry.geometry2d import Point2D, Vector2D, Polygon2D
from ladybug_geometry.geometry3d.pointvector import Vector3D, Point3D
from ladybug_geometry.geometry3d import LineSegment3D, Plane, Face3D
from ladybug_geometry.bounding import bounding_rectangle

from honeybee.typing import float_in_range, float_positive
from honeybee.boundarycondition import Surface
from honeybee.aperture import Aperture
from honeybee.door import Door


class _WindowParameterBase(object):
    """Base object for all window parameters.

    This object records all of the methods that must be overwritten on a window
    parameter object for it to be successfully be applied in dragonfly workflows.

    Properties:
        * user_data
    """
    __slots__ = ('_user_data',)

    def __init__(self):
        self._user_data = None

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

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

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

    def area_from_segment(self, segment, floor_to_ceiling_height):
        """Get the window area generated by these parameters from a LineSegment3D."""
        return 0

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

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

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

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

    def trim(self, original_segment, sub_segment, tolerance=0.01):
        """Trim window parameters for a sub segment given the original segment.

        Args:
            original_segment: The original LineSegment3D to which the window
                parameters are assigned.
            sub_segment: A LineSegment3D that is a sub-segment of the original_segment,
                which will be used to trim the window parameters to fit this segment.
                Note that this sub_segment should have the same orientation as
                the original segment.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        return self  # windows are assumed to repeat

    @staticmethod
    def merge_to_rectangular(window_parameters, segments, floor_to_ceiling_height):
        """Merge any window parameters together into rectangular windows.

        Args:
            window_parameters: A list of WindowParameters to be merged.
            segments: The segments to which the window parameters are assigned.
                These should be in order as they appear on the parent Room2D.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        base_x = 0
        origins, widths, heights, doors = [], [], [], []
        for wp, s in zip(window_parameters, segments):
            if wp is not None:
                rwp = wp.to_rectangular_windows(s, floor_to_ceiling_height)
                zip_obj = zip(rwp.origins, rwp.widths, rwp.heights, rwp.are_doors)
                for o, w, h, d in zip_obj:
                    origins.append(Point2D(o.x + base_x, o.y))
                    widths.append(w)
                    heights.append(h)
                    doors.append(d)
            base_x += s.length
        return RectangularWindows(origins, widths, heights, doors)

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

        .. code-block:: python

            {
            "type": "WindowParameterBase"
            }
        """
        assert data['type'] == 'WindowParameterBase', \
            'Expected WindowParameterBase dictionary. Got {}.'.format(data['type'])
        new_w_par = cls()
        if 'user_data' in data and data['user_data'] is not None:
            new_w_par.user_data = data['user_data']
        return new_w_par

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

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

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

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

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

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

    def __copy__(self):
        return _WindowParameterBase()

    def __repr__(self):
        return 'WindowParameterBase'


class SingleWindow(_WindowParameterBase):
    """Instructions for a single window in the face center defined by a width x height.

    Note that, if these parameters are applied to a base face that is too short
    or too narrow for the input width and/or height, the generated window will
    automatically be shortened when it is applied to the face. In this way,
    setting the width to be `float('inf')` will create parameters that always
    generate a ribbon window of the input height.

    Args:
        width: A number for the window width.
        height: A number for the window height.
        sill_height: A number for the window sill height. Default: 1.

    Properties:
        * width
        * height
        * sill_height
        * user_data
    """
    __slots__ = ('_width', '_height', '_sill_height')

    def __init__(self, width, height, sill_height=1):
        """Initialize SingleWindow."""
        _WindowParameterBase.__init__(self)  # add the user_data
        self._width = float_positive(width, 'window width')
        self._height = float_positive(height, 'window height')
        self._sill_height = float_positive(sill_height, 'window sill height')

    @property
    def width(self):
        """Get a number for the window width."""
        return self._width

    @property
    def height(self):
        """Get a number for the window height."""
        return self._height

    @property
    def sill_height(self):
        """Get a number for the sill height."""
        return self._sill_height

    def area_from_segment(self, segment, floor_to_ceiling_height):
        """Get the window area generated by these parameters from a LineSegment3D.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        max_width = segment.length
        max_height = floor_to_ceiling_height - self.sill_height
        final_width = max_width if self.width > max_width else self.width
        final_height = max_height if self.height > max_height else self.height
        if final_height < 0:
            return 0
        else:
            return final_width * final_height

    def to_rectangular_windows(self, segment, floor_to_ceiling_height):
        """Get a version of these WindowParameters as RectangularWindows.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segments belong.
        """
        max_width = segment.length
        max_height = (floor_to_ceiling_height) - self.sill_height
        final_width = max_width if self.width > max_width else self.width
        final_height = max_height if self.height > max_height else self.height
        if final_height < 0:
            return None
        else:
            origin = Point2D((segment.length - final_width) / 2, self.sill_height)
            new_w = RectangularWindows([origin], [final_width], [final_height])
            new_w._user_data = None if self.user_data is None else self.user_data.copy()
            return new_w

    def add_window_to_face(self, face, tolerance=0.01):
        """Add Apertures to a Honeybee Face using these Window Parameters.

        Args:
            face: A honeybee-core Face object.
            tolerance: Optional tolerance value. Default: 0.01, suitable for
                objects in meters.
        """
        if self._width == 0 or self._height == 0:
            return None
        width_seg = LineSegment3D.from_end_points(face.geometry[0], face.geometry[1])
        height_seg = LineSegment3D.from_end_points(face.geometry[1], face.geometry[2])
        max_width = width_seg.length - tolerance
        max_height = (height_seg.length - tolerance) - self.sill_height
        final_width = max_width if self.width > max_width else self.width
        final_height = max_height if self.height > max_height else self.height
        if final_height > 0:
            face.aperture_by_width_height(final_width, final_height, self.sill_height)
            # if the Aperture is interior, set adjacent boundary condition
            if isinstance(face._boundary_condition, Surface):
                ids = face._boundary_condition.boundary_condition_objects
                adj_ap_id = '{}_Glz1'.format(ids[0])
                final_ids = (adj_ap_id,) + ids
                face.apertures[0].boundary_condition = Surface(final_ids, True)
        self._apply_user_data_to_honeybee(face.apertures)

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

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

        Args:
            factor: A number representing how much the object should be scaled.
        """
        new_w = SingleWindow(
            self.width * factor, self.height * factor, self.sill_height * factor)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def split(self, segments, tolerance=0.01):
        """Split SingleWindow parameters across a list of ordered segments.

        Args:
            segments: The segments to which the window parameters are being
                split across. These should be in order as they appear on the
                parent Room2D.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        lengths = [s.length for s in segments]
        total_len = sum(lengths)
        new_w_par = []
        for length in lengths:
            new_w = (length / total_len) * self.width
            new_w_par.append(SingleWindow(new_w, self.height, self.sill_height))
        self._add_user_data(new_w_par)
        return new_w_par

    @staticmethod
    def merge(window_parameters, segments, floor_to_ceiling_height):
        """Merge SingleWindow parameters together using their assigned segments.

        Args:
            window_parameters: A list of WindowParameters to be merged.
            segments: The segments to which the window parameters are assigned.
                These should be in order as they appear on the parent Room2D.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segments belong.
        """
        width = 0
        weights, heights, sill_heights = [], [], [], []
        for wp, s in zip(window_parameters, segments):
            if wp is not None:
                if isinstance(wp, SingleWindow):
                    weights.append(s.length)
                    width += wp.width
                    heights.append(wp.height)
                    sill_heights.append(wp.length)
                else:  # not all windows are of the same type; convert all to rectangular
                    return _WindowParameterBase.merge_to_rectangular(
                        window_parameters, segments, floor_to_ceiling_height)
        tw = sum(weights)
        weights = [w / tw for w in weights]
        height = sum([h * w for h, w in zip(heights, weights)])
        sill_height = sum([h * w for h, w in zip(sill_heights, weights)])
        new_w = SingleWindow(width, height, sill_height)
        new_w._user_data = None if window_parameters[0].user_data is None else \
            window_parameters[0].user_data.copy()
        return new_w

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

        .. code-block:: python

            {
            "type": "SingleWindow",
            "width": 100,
            "height": 1.5,
            "sill_height": 0.8
            }
        """
        assert data['type'] == 'SingleWindow', \
            'Expected SingleWindow dictionary. Got {}.'.format(data['type'])
        sill = data['sill_height'] if 'sill_height' in data else 1
        new_w_par = cls(data['width'], data['height'], sill)
        if 'user_data' in data and data['user_data'] is not None:
            new_w_par.user_data = data['user_data']
        return new_w_par

    def to_dict(self):
        """Get SingleWindow as a dictionary."""
        base = {
            'type': 'SingleWindow',
            'width': self.width,
            'height': self.height,
            'sill_height': self.sill_height
        }
        if self.user_data is not None:
            base['user_data'] = self.user_data
        return base

    def __copy__(self):
        new_w = SingleWindow(self.width, self.height, self.sill_height)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def __key(self):
        """A tuple based on the object properties, useful for hashing."""
        return (self.width, self.height, self.sill_height)

    def __hash__(self):
        return hash(self.__key())

    def __eq__(self, other):
        return isinstance(other, SingleWindow) and self.__key() == other.__key()

    def __ne__(self, other):
        return not self.__eq__(other)

    def __repr__(self):
        return 'SingleWindow: [width: {}] [height: {}] [sill_height: {}]'.format(
            self.width, self.height, self.sill_height)


class SimpleWindowArea(_WindowParameterBase):
    """Instructions for a single window defined by an absolute area.

    Properties:
        * window_area
        * user_data

    Args:
        window_area: A number for the window area in current model units.
            If this area is larger than the area of the Wall that it is applied
            to, the window will fill the parent Wall at a 99% ratio.
    """
    __slots__ = ('_window_area',)

    def __init__(self, window_area):
        """Initialize SimpleWindowArea."""
        _WindowParameterBase.__init__(self)  # add the user_data
        self._window_area = float_positive(window_area, 'window area')

    @property
    def window_area(self):
        """Get a number for the window area in current model units."""
        return self._window_area

    def area_from_segment(self, segment, floor_to_ceiling_height):
        """Get the window area generated by these parameters from a LineSegment3D.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        wall_area = segment.length * floor_to_ceiling_height * 0.99
        return self.window_area if self.window_area < wall_area else wall_area

    def to_rectangular_windows(self, segment, floor_to_ceiling_height):
        """Get a version of these WindowParameters as RectangularWindows.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        if self._window_area == 0:
            return None
        wall_area = segment.length * floor_to_ceiling_height * 0.99
        window_ratio = self.window_area / wall_area
        window_ratio = 0.99 if window_ratio > 0.99 else window_ratio
        scale_factor = window_ratio ** 0.5
        final_height = floor_to_ceiling_height * scale_factor
        final_width = segment.length * scale_factor
        origin = Point2D((segment.length - final_width) / 2,
                         (floor_to_ceiling_height - final_height) / 2)
        new_w = RectangularWindows([origin], [final_width], [final_height])
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def add_window_to_face(self, face, tolerance=0.01):
        """Add Apertures to a Honeybee Face using these Window Parameters.

        Args:
            face: A honeybee-core Face object.
            tolerance: Optional tolerance value. Default: 0.01, suitable for
                objects in meters.
        """
        if self._window_area == 0:
            return None
        window_ratio = self.window_area / face.area
        window_ratio = 0.99 if window_ratio > 0.99 else window_ratio
        face.apertures_by_ratio(window_ratio, tolerance)
        # if the Aperture is interior, set adjacent boundary condition
        if isinstance(face._boundary_condition, Surface):
            num_aps = face.apertures
            for i, ap in enumerate(face.apertures):
                ids = face._boundary_condition.boundary_condition_objects
                adj_ap_id = '{}_Glz{}'.format(ids[0], num_aps - i - 1)
                final_ids = (adj_ap_id,) + ids
                ap.boundary_condition = Surface(final_ids, True)
        self._apply_user_data_to_honeybee(face.apertures)

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

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

        Args:
            factor: A number representing how much the object should be scaled.
        """
        new_w = SimpleWindowArea(self.window_area * factor)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def split(self, segments, tolerance=0.01):
        """Split SimpleWindowArea parameters across a list of ordered segments.

        Args:
            segments: The segments to which the window parameters are being
                split across. These should be in order as they appear on the
                parent Room2D.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        # if one of the segments is much larger than the others, add all windows to that
        lengths = [s.length for s in segments]
        new_wps = self._all_to_primary_segment(lengths)
        if new_wps is not None:
            return new_wps
        # otherwise, just distribute the windows evenly
        total_len = sum(lengths)
        n_par = [SimpleWindowArea(self.window_area * (sl / total_len)) for sl in lengths]
        self._add_user_data(n_par)
        return n_par

    def trim(self, original_segment, sub_segment, tolerance=0.01):
        """Trim window parameters for a sub segment given the original segment.

        Args:
            original_segment: The original LineSegment3D to which the window
                parameters are assigned.
            sub_segment: A LineSegment3D that is a sub-segment of the original_segment,
                which will be used to trim the window parameters to fit this segment.
                Note that this sub_segment should have the same orientation as
                the original segment.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        # evenly distribute the windows
        len_ratio = sub_segment.length / original_segment.length
        n_par = SimpleWindowArea(self.window_area * len_ratio)
        n_par._user_data = self._user_data
        return n_par

    @staticmethod
    def merge(window_parameters, segments, floor_to_ceiling_height):
        """Merge SimpleWindowArea parameters together using their assigned segments.

        Args:
            window_parameters: A list of WindowParameters to be merged.
            segments: The segments to which the window parameters are assigned.
                These should be in order as they appear on the parent Room2D.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segments belong.
        """
        win_area = 0
        for wp in window_parameters:
            if wp is not None:
                if isinstance(wp, SimpleWindowArea):
                    win_area += wp.window_area
                else:  # not all windows are of the same type; convert all to rectangular
                    return _WindowParameterBase.merge_to_rectangular(
                        window_parameters, segments, floor_to_ceiling_height)
        new_w = SimpleWindowArea(win_area)
        new_w._user_data = None if window_parameters[0].user_data is None else \
            window_parameters[0].user_data.copy()
        return new_w

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

        .. code-block:: python

            {
            "type": "SimpleWindowArea",
            "window_area": 5.5
            }
        """
        assert data['type'] == 'SimpleWindowArea', \
            'Expected SimpleWindowArea dictionary. Got {}.'.format(data['type'])
        new_w_par = cls(data['window_area'])
        if 'user_data' in data and data['user_data'] is not None:
            new_w_par.user_data = data['user_data']
        return new_w_par

    def to_dict(self):
        """Get SimpleWindowArea as a dictionary."""
        base = {
            'type': 'SimpleWindowArea',
            'window_area': self.window_area
        }
        if self.user_data is not None:
            base['user_data'] = self.user_data
        return base

    def _all_to_primary_segment(self, lengths, prim_ratio=0.95):
        """Determine if one segment is primary and should get all the windows."""
        total_len = sum(lengths)
        prim_len = prim_ratio + 0.01
        all_to_one_i = None
        for i, sl in enumerate(lengths):
            if sl / total_len > prim_len:
                all_to_one_i = i
                break
        if all_to_one_i is not None:
            new_wps = [None] * len(lengths)
            new_par = self.duplicate()
            new_wps[all_to_one_i] = new_par
            return new_wps

    def __copy__(self):
        new_w = SimpleWindowArea(self.window_area)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def __key(self):
        """A tuple based on the object properties, useful for hashing."""
        return self._window_area

    def __hash__(self):
        return hash(self.__key())

    def __eq__(self, other):
        return isinstance(other, SimpleWindowArea) and self.__key() == other.__key()

    def __ne__(self, other):
        return not self.__eq__(other)

    def __repr__(self):
        return 'SimpleWindowArea: [area: {}]'.format(self.window_area)


class SimpleWindowRatio(_WindowParameterBase):
    """Instructions for a single window defined by an area ratio with the base wall.

    Properties:
        * window_ratio
        * user_data

    Args:
        window_ratio: A number between 0 and 1 for the ratio between the window
            area and the parent wall surface area.
    """
    __slots__ = ('_window_ratio',)

    def __init__(self, window_ratio):
        """Initialize SimpleWindowRatio."""
        _WindowParameterBase.__init__(self)  # add the user_data
        self._window_ratio = float_in_range(window_ratio, 0, 1, 'window ratio')

    @property
    def window_ratio(self):
        """Get a number between 0 and 1 for the window ratio."""
        return self._window_ratio

    def area_from_segment(self, segment, floor_to_ceiling_height):
        """Get the window area generated by these parameters from a LineSegment3D.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        return segment.length * floor_to_ceiling_height * self._window_ratio

    def to_rectangular_windows(self, segment, floor_to_ceiling_height):
        """Get a version of these WindowParameters as RectangularWindows.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        if self._window_ratio == 0:
            return None
        scale_factor = self.window_ratio ** 0.5
        final_height = floor_to_ceiling_height * scale_factor
        final_width = segment.length * scale_factor
        origin = Point2D((segment.length - final_width) / 2,
                         (floor_to_ceiling_height - final_height) / 2)
        new_w = RectangularWindows([origin], [final_width], [final_height])
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def add_window_to_face(self, face, tolerance=0.01):
        """Add Apertures to a Honeybee Face using these Window Parameters.

        Args:
            face: A honeybee-core Face object.
            tolerance: Optional tolerance value. Default: 0.01, suitable for
                objects in meters.
        """
        if self._window_ratio == 0:
            return None
        scale_factor = self.window_ratio ** .5
        ap_face = face.geometry.scale(scale_factor, face.geometry.center)
        aperture = Aperture('{}_Glz1'.format(face.identifier), ap_face)
        aperture._parent = face
        face.add_aperture(aperture)
        # if the Aperture is interior, set adjacent boundary condition
        if isinstance(face._boundary_condition, Surface):
            ids = face._boundary_condition.boundary_condition_objects
            adj_ap_id = '{}_Glz1'.format(ids[0])
            final_ids = (adj_ap_id,) + ids
            aperture.boundary_condition = Surface(final_ids, True)
        self._apply_user_data_to_honeybee(face.apertures)

    def split(self, segments, tolerance=0.01):
        """Split SimpleWindowRatio parameters across a list of ordered segments.

        Args:
            segments: The segments to which the window parameters are being
                split across. These should be in order as they appear on the
                parent Room2D.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        # if one of the segments is much larger than the others, add all windows to that
        new_ratios = self._all_to_primary_segment(segments)
        if new_ratios is not None:
            return new_ratios
        # otherwise, just distribute the windows evenly
        new_w_par = [self] * len(segments)
        self._add_user_data(new_w_par)
        return new_w_par

    @staticmethod
    def merge(window_parameters, segments, floor_to_ceiling_height):
        """Merge SimpleWindowRatio parameters together using their assigned segments.

        Args:
            window_parameters: A list of WindowParameters to be merged.
            segments: The segments to which the window parameters are assigned.
                These should be in order as they appear on the parent Room2D.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segments belong.
        """
        win_area, wall_area = 0, 0
        for wp, s in zip(window_parameters, segments):
            if wp is not None:
                wall_a = s.length * floor_to_ceiling_height
                wall_area += wall_a
                if isinstance(wp, SimpleWindowRatio):
                    win_area += (wall_area * wp.window_ratio)
                else:  # not all windows are of the same type; convert all to rectangular
                    return _WindowParameterBase.merge_to_rectangular(
                        window_parameters, segments, floor_to_ceiling_height)
        new_w = SimpleWindowRatio(win_area / wall_area)
        new_w._user_data = None if window_parameters[0].user_data is None else \
            window_parameters[0].user_data.copy()
        return new_w

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

        .. code-block:: python

            {
            "type": "SimpleWindowRatio",
            "window_ratio": 0.4
            }
        """
        assert data['type'] == 'SimpleWindowRatio', \
            'Expected SimpleWindowRatio dictionary. Got {}.'.format(data['type'])
        new_w_par = cls(data['window_ratio'])
        if 'user_data' in data and data['user_data'] is not None:
            new_w_par.user_data = data['user_data']
        return new_w_par

    def to_dict(self):
        """Get SimpleWindowRatio as a dictionary."""
        base = {
            'type': 'SimpleWindowRatio',
            'window_ratio': self.window_ratio
        }
        if self.user_data is not None:
            base['user_data'] = self.user_data
        return base

    def _all_to_primary_segment(self, segments, prim_ratio=0.95):
        """Determine if one segment is primary and should get all the windows."""
        if self.window_ratio <= prim_ratio:
            lengths = [s.length for s in segments]
            total_len = sum(lengths)
            prim_len = prim_ratio + 0.01
            all_to_one_i = None
            for i, sl in enumerate(lengths):
                if sl / total_len > prim_len:
                    all_to_one_i = i
                    all_to_one_ratio = self.window_ratio * (total_len / sl)
                    break
            if all_to_one_i is not None:
                new_ratios = [None] * len(segments)
                new_par = self.duplicate()
                new_par._window_ratio = all_to_one_ratio
                new_ratios[all_to_one_i] = new_par
                return new_ratios

    def __copy__(self):
        new_w = SimpleWindowRatio(self.window_ratio)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def __key(self):
        """A tuple based on the object properties, useful for hashing."""
        return self._window_ratio

    def __hash__(self):
        return hash(self.__key())

    def __eq__(self, other):
        return isinstance(other, SimpleWindowRatio) and self.__key() == other.__key()

    def __ne__(self, other):
        return not self.__eq__(other)

    def __repr__(self):
        return 'SimpleWindowRatio: [ratio: {}]'.format(self.window_ratio)


class RepeatingWindowRatio(SimpleWindowRatio):
    """Instructions for repeating windows derived from an area ratio with the base face.

    Args:
        window_ratio: A number between 0 and 0.95 for the ratio between the window
            area and the total facade area.
        window_height: A number for the target height of the windows.
            Note that, if the window ratio is too large for the height, the
            ratio will take precedence and the actual window_height will
            be larger than this value.
        sill_height: A number for the target height above the bottom edge of
            the rectangle to start the windows. Note that, if the
            ratio is too large for the height, the ratio will take precedence
            and the sill_height will be smaller than this value.
        horizontal_separation: A number for the target separation between
            individual window center lines.  If this number is larger than
            the parent rectangle base, only one window will be produced.
        vertical_separation: An optional number to create a single vertical
            separation between top and bottom windows. Default: 0.

    Properties:
        * window_ratio
        * window_height
        * sill_height
        * horizontal_separation
        * vertical_separation
        * user_data
    """
    __slots__ = ('_window_height', '_sill_height',
                 '_horizontal_separation', '_vertical_separation')

    def __init__(self, window_ratio, window_height, sill_height,
                 horizontal_separation, vertical_separation=0):
        """Initialize RepeatingWindowRatio."""
        _WindowParameterBase.__init__(self)  # add the user_data
        self._window_ratio = float_in_range(window_ratio, 0, 0.95, 'window ratio')
        self._window_height = float_positive(window_height, 'window height')
        self._sill_height = float_positive(sill_height, 'sill height')
        self._horizontal_separation = \
            float_positive(horizontal_separation, 'window horizontal separation')
        self._vertical_separation = \
            float_positive(vertical_separation, 'window vertical separation')

    @property
    def window_height(self):
        """Get a number or the target height of the windows."""
        return self._window_height

    @property
    def sill_height(self):
        """Get a number for the height above the bottom edge of the floor."""
        return self._sill_height

    @property
    def horizontal_separation(self):
        """Get a number for the separation between individual window center lines."""
        return self._horizontal_separation

    @property
    def vertical_separation(self):
        """Get a number for a vertical separation between top/bottom windows."""
        return self._vertical_separation

    def to_rectangular_windows(self, segment, floor_to_ceiling_height):
        """Get a version of these WindowParameters as RectangularWindows.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        if self._window_ratio == 0:
            return None
        base_plane = Plane(segment.v.rotate_xy(math.pi / 2), segment.p, segment.v)
        sub_rects = Face3D.sub_rects_from_rect_ratio(
            base_plane, segment.length, floor_to_ceiling_height,
            self.window_ratio, self.window_height, self.sill_height,
            self.horizontal_separation, self.vertical_separation
        )
        origins, widths, heights = [], [], []
        for rect in sub_rects:
            poly = Polygon2D(tuple(base_plane.xyz_to_xy(pt) for pt in rect))
            min_pt, max_pt = poly.min, poly.max
            origins.append(min_pt)
            widths.append(max_pt.x - min_pt.x)
            heights.append(max_pt.y - min_pt.y)
        new_w = RectangularWindows(origins, widths, heights)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def add_window_to_face(self, face, tolerance=0.01):
        """Add Apertures to a Honeybee Face using these Window Parameters.

        Args:
            face: A honeybee-core Face object.
            tolerance: The maximum difference between point values for them to be
                considered a part of a rectangle. Default: 0.01, suitable for
                objects in meters.
        """
        if self._window_ratio == 0:
            return None
        face.apertures_by_ratio_rectangle(
            self.window_ratio, self.window_height, self.sill_height,
            self.horizontal_separation, self.vertical_separation, tolerance)
        # if the Aperture is interior, set adjacent boundary condition
        if isinstance(face._boundary_condition, Surface):
            num_aps = face.apertures
            for i, ap in enumerate(face.apertures):
                ids = face._boundary_condition.boundary_condition_objects
                adj_ap_id = '{}_Glz{}'.format(ids[0], num_aps - i - 1)
                final_ids = (adj_ap_id,) + ids
                ap.boundary_condition = Surface(final_ids, True)
        self._apply_user_data_to_honeybee(face.apertures)

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

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

        Args:
            factor: A number representing how much the object should be scaled.
        """
        new_w = RepeatingWindowRatio(
            self.window_ratio, self.window_height * factor, self.sill_height * factor,
            self.horizontal_separation * factor, self.vertical_separation * factor)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def split(self, segments, tolerance=0.01):
        """Split RepeatingWindowRatio parameters across a list of ordered segments.

        Args:
            segments: The segments to which the window parameters are being
                split across. These should be in order as they appear on the
                parent Room2D.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        # if one of the segments is much larger than the others, add all windows to that
        new_ratios = self._all_to_primary_segment(segments)
        if new_ratios is not None:
            return new_ratios
        # otherwise, just distribute the windows evenly
        new_w_par = [self] * len(segments)
        self._add_user_data(new_w_par)
        return new_w_par

    @staticmethod
    def merge(window_parameters, segments, floor_to_ceiling_height):
        """Merge RepeatingWindowRatio parameters together using their assigned segments.

        Args:
            window_parameters: A list of WindowParameters to be merged.
            segments: The segments to which the window parameters are assigned.
                These should be in order as they appear on the parent Room2D.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segments belong.
        """
        weights, heights, sill_heights, h_seps, v_seps = [], [], [], [], []
        win_area, wall_area = 0, 0
        for wp, s in zip(window_parameters, segments):
            if wp is not None:
                wall_a = s.length * floor_to_ceiling_height
                wall_area += wall_a
                if isinstance(wp, RepeatingWindowRatio):
                    win_area += (wall_area * wp.window_ratio)
                    weights.append(s.length)
                    heights.append(wp.window_height)
                    sill_heights.append(wp.sill_height)
                    h_seps.append(wp.horizontal_separation)
                    v_seps.append(wp.vertical_separation)
                else:  # not all windows are of the same type; convert all to rectangular
                    return _WindowParameterBase.merge_to_rectangular(
                        window_parameters, segments, floor_to_ceiling_height)
        window_ratio = win_area / wall_area
        tw = sum(weights)
        weights = [w / tw for w in weights]
        height = sum([h * w for h, w in zip(heights, weights)])
        sill_height = sum([h * w for h, w in zip(sill_heights, weights)])
        h_sep = sum([s * w for s, w in zip(h_seps, weights)])
        v_sep = sum([s * w for s, w in zip(v_seps, weights)])
        new_w = RepeatingWindowRatio(window_ratio, height, sill_height, h_sep, v_sep)
        new_w._user_data = None if window_parameters[0].user_data is None else \
            window_parameters[0].user_data.copy()
        return new_w

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

        .. code-block:: python

            {
            "type": "RepeatingWindowRatio",
            "window_ratio": 0.4,
            "window_height": 2,
            "sill_height": 0.8,
            "horizontal_separation": 4,
            "vertical_separation": 0.5
            }
        """
        assert data['type'] == 'RepeatingWindowRatio', \
            'Expected RepeatingWindowRatio dictionary. Got {}.'.format(data['type'])
        vert = data['vertical_separation'] if 'vertical_separation' in data else 0
        new_w_par = cls(data['window_ratio'], data['window_height'],
                        data['sill_height'], data['horizontal_separation'], vert)
        if 'user_data' in data and data['user_data'] is not None:
            new_w_par.user_data = data['user_data']
        return new_w_par

    def to_dict(self):
        """Get RepeatingWindowRatio as a dictionary."""
        base = {
            'type': 'RepeatingWindowRatio',
            'window_ratio': self.window_ratio,
            'window_height': self.window_height,
            'sill_height': self.sill_height,
            'horizontal_separation': self.horizontal_separation
        }
        if self.vertical_separation != 0:
            base['vertical_separation'] = self.vertical_separation
        if self.user_data is not None:
            base['user_data'] = self.user_data
        return base

    def __copy__(self):
        new_w = RepeatingWindowRatio(
            self._window_ratio, self._window_height, self._sill_height,
            self._horizontal_separation, self._vertical_separation)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def __key(self):
        """A tuple based on the object properties, useful for hashing."""
        return (self._window_ratio, self._window_height, self._sill_height,
                self._horizontal_separation, self._vertical_separation)

    def __hash__(self):
        return hash(self.__key())

    def __eq__(self, other):
        return isinstance(other, RepeatingWindowRatio) and self.__key() == other.__key()

    def __ne__(self, other):
        return not self.__eq__(other)

    def __repr__(self):
        return 'RepeatingWindowRatio: [ratio: {}] [window_height: {}] [sill_height:' \
            ' {}] [horizontal: {}] [vertical: {}]'.format(
                self._window_ratio, self.window_height, self.sill_height,
                self.horizontal_separation, self.vertical_separation)


class RepeatingWindowWidthHeight(_WindowParameterBase):
    """Instructions for repeating rectangular windows of a fixed width and height.

    This class effectively fills a wall with windows at the specified width, height
    and separation.

    Args:
        window_height: A number for the target height of the windows.
            Note that, if the window_height is larger than the height of the wall,
            the generated windows will have a height equal to the wall height
            in order to avoid having windows extend outside the wall face.
        window_width: A number for the target width of the windows.
            Note that, if the window_width is larger than the width of the wall,
            the generated windows will have a width equal to the wall width
            in order to avoid having windows extend outside the wall face.
        sill_height: A number for the target height above the bottom edge of
            the wall to start the windows. If the window_height
            is too large for the sill_height to fit within the rectangle,
            the window_height will take precedence.
        horizontal_separation: A number for the target separation between
            individual window center lines.  If this number is larger than
            the parent rectangle base, only one window will be produced.

    Properties:
        * window_height
        * window_width
        * sill_height
        * horizontal_separation
        * user_data
    """
    __slots__ = ('_window_height', '_window_width', '_sill_height',
                 '_horizontal_separation')

    def __init__(self, window_height, window_width, sill_height, horizontal_separation):
        """Initialize RepeatingWindowWidthHeight."""
        _WindowParameterBase.__init__(self)  # add the user_data
        self._window_height = float_positive(window_height, 'window height')
        self._window_width = float_positive(window_width, 'window width')
        self._sill_height = float_positive(sill_height, 'sill height')
        self._horizontal_separation = \
            float_positive(horizontal_separation, 'window horizontal separation')

    @property
    def window_height(self):
        """Get a number or the target height of the windows."""
        return self._window_height

    @property
    def window_width(self):
        """Get a number or the target width of the windows."""
        return self._window_width

    @property
    def sill_height(self):
        """Get a number for the height above the bottom edge of the floor."""
        return self._sill_height

    @property
    def horizontal_separation(self):
        """Get a number for the separation between individual window center lines."""
        return self._horizontal_separation

    def area_from_segment(self, segment, floor_to_ceiling_height):
        """Get the window area generated by these parameters from a LineSegment3D.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        hgt = floor_to_ceiling_height - 0.02 * floor_to_ceiling_height if \
            self.window_height >= floor_to_ceiling_height else self.window_height
        max_width_break_up = segment.length / 2
        if self.window_width < max_width_break_up:  # multiple windows
            num_div = round(segment.length / self.horizontal_separation) if \
                segment.length > self.horizontal_separation / 2 else 1
            if num_div * self.window_width + (num_div - 1) * \
                    (self.horizontal_separation - self.window_width) > segment.length:
                num_div = math.floor(segment.length / self.horizontal_separation)
            return num_div * self.window_width * hgt
        else:  # one single window
            return segment.length * 0.98 * hgt

    def to_rectangular_windows(self, segment, floor_to_ceiling_height):
        """Get a version of these WindowParameters as RectangularWindows.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        base_plane = Plane(segment.v.rotate_xy(math.pi / 2), segment.p, segment.v)
        sub_rects = Face3D.sub_rects_from_rect_dimensions(
            base_plane, segment.length, floor_to_ceiling_height, self.window_height,
            self.window_width, self.sill_height, self.horizontal_separation
        )
        origins, widths, heights = [], [], []
        for rect in sub_rects:
            poly = Polygon2D(tuple(base_plane.xyz_to_xy(pt) for pt in rect))
            min_pt, max_pt = poly.min, poly.max
            origins.append(min_pt)
            widths.append(max_pt.x - min_pt.x)
            heights.append(max_pt.y - min_pt.y)
        new_w = RectangularWindows(origins, widths, heights)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def add_window_to_face(self, face, tolerance=0.01):
        """Add Apertures to a Honeybee Face using these Window Parameters.

        Args:
            face: A honeybee-core Face object.
            tolerance: The maximum difference between point values for them to be
                considered a part of a rectangle. Default: 0.01, suitable for
                objects in meters.
        """
        if self._window_width == 0 or self._window_height == 0:
            return None
        face.apertures_by_width_height_rectangle(
            self.window_height, self.window_width, self.sill_height,
            self.horizontal_separation, tolerance)
        # if the Aperture is interior, set adjacent boundary condition
        if isinstance(face._boundary_condition, Surface):
            num_aps = face.apertures
            for i, ap in enumerate(face.apertures):
                ids = face._boundary_condition.boundary_condition_objects
                adj_ap_id = '{}_Glz{}'.format(ids[0], num_aps - i - 1)
                final_ids = (adj_ap_id,) + ids
                ap.boundary_condition = Surface(final_ids, True)
        self._apply_user_data_to_honeybee(face.apertures)

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

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

        Args:
            factor: A number representing how much the object should be scaled.
        """
        new_w = RepeatingWindowWidthHeight(
            self.window_height * factor, self.window_width * factor,
            self.sill_height * factor, self.horizontal_separation * factor)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def split(self, segments, tolerance=0.01):
        """Split RepeatingWindowWidthHeight parameters across a list of ordered segments.

        Args:
            segments: The segments to which the window parameters are being
                split across. These should be in order as they appear on the
                parent Room2D.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        # just distribute the windows evenly
        new_w_par = [self] * len(segments)
        self._add_user_data(new_w_par)
        return new_w_par

    @staticmethod
    def merge(window_parameters, segments, floor_to_ceiling_height):
        """Merge RepeatingWindowWidthHeight parameters using their assigned segments.

        Args:
            window_parameters: A list of WindowParameters to be merged.
            segments: The segments to which the window parameters are assigned.
                These should be in order as they appear on the parent Room2D.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segments belong.
        """
        weights, heights, widths, sill_heights, h_seps = [], [], [], [], []
        for wp, s in zip(window_parameters, segments):
            if wp is not None:
                if isinstance(wp, RepeatingWindowWidthHeight):
                    weights.append(s.length)
                    heights.append(wp.window_height)
                    widths.append(wp.window_width)
                    sill_heights.append(wp.sill_height)
                    h_seps.append(wp.horizontal_separation)
                else:  # not all windows are of the same type; convert all to rectangular
                    return _WindowParameterBase.merge_to_rectangular(
                        window_parameters, segments, floor_to_ceiling_height)
        tw = sum(weights)
        weights = [w / tw for w in weights]
        height = sum([h * w for h, w in zip(heights, weights)])
        width = sum([x * w for x, w in zip(widths, weights)])
        sill_height = sum([h * w for h, w in zip(sill_heights, weights)])
        h_sep = sum([s * w for s, w in zip(h_seps, weights)])
        new_w = RepeatingWindowWidthHeight(height, width, sill_height, h_sep)
        new_w._user_data = None if window_parameters[0].user_data is None else \
            window_parameters[0].user_data.copy()
        return new_w

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

        .. code-block:: python

            {
            "type": "RepeatingWindowWidthHeight",
            "window_height": 2,
            "window_width": 1.5,
            "sill_height": 0.8,
            "horizontal_separation": 4
            }
        """
        assert data['type'] == 'RepeatingWindowWidthHeight', 'Expected ' \
            'RepeatingWindowWidthHeight dictionary. Got {}.'.format(data['type'])
        new_w_par = cls(data['window_height'], data['window_width'],
                        data['sill_height'], data['horizontal_separation'])
        if 'user_data' in data and data['user_data'] is not None:
            new_w_par.user_data = data['user_data']
        return new_w_par

    def to_dict(self):
        """Get RepeatingWindowWidthHeight as a dictionary."""
        base = {
            'type': 'RepeatingWindowWidthHeight',
            'window_height': self.window_height,
            'window_width': self.window_width,
            'sill_height': self.sill_height,
            'horizontal_separation': self.horizontal_separation
        }
        if self.user_data is not None:
            base['user_data'] = self.user_data
        return base

    def __copy__(self):
        new_w = RepeatingWindowWidthHeight(
            self._window_height, self._window_width, self._sill_height,
            self._horizontal_separation)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def __key(self):
        """A tuple based on the object properties, useful for hashing."""
        return (self._window_height, self._window_width, self._sill_height,
                self._horizontal_separation)

    def __hash__(self):
        return hash(self.__key())

    def __eq__(self, other):
        return isinstance(other, RepeatingWindowWidthHeight) and \
            self.__key() == other.__key()

    def __ne__(self, other):
        return not self.__eq__(other)

    def __repr__(self):
        return 'RepeatingWindowWidthHeight: [window_height: {}] [window_width: ' \
            '{}] [sill_height: {}] [horizontal: {}]'.format(
                self.window_height, self.window_width, self.sill_height,
                self.horizontal_separation)


class _AsymmetricBase(_WindowParameterBase):
    """Base class for WindowParameters that can make asymmetric windows on a wall.
    """

    def flip(self, seg_length):
        """Flip the direction of the windows along a wall segment.

        This is needed since windows can exist asymmetrically across the wall
        segment and operations like reflecting the Room2D across a plane will
        require the window parameters to be flipped. Reversing the Room2D vertices
        also requires flipping.

        Args:
            seg_length: The length of the segment along which the parameters are
                being flipped.
        """
        return self

    def _merge_user_data(self, original_w_par):
        """Build this object's user_data from those of merged window parameters.

        Args:
            original_w_par: An array of the original window parameters used to
                build this one.
        """
        if not all((owp is None or owp.user_data is None) for owp in original_w_par):
            new_u = {}
            for ow_par in original_w_par:
                if ow_par is not None and ow_par.user_data is not None:
                    for key, val in ow_par.user_data.items():
                        if key not in new_u:
                            new_u[key] = val
                        elif isinstance(new_u[key], (list, tuple)) and \
                                isinstance(val, (list, tuple)) and \
                                len(val) >= len(ow_par):
                            new_u[key] = new_u[key] + val
            self.user_data = new_u

    def _split_user_data(self, split_win_par, win_par_indices):
        """Assign user_data to split WindowParameters with the help of split indices.

        Args:
            split_win_par: An array of WindowParameters that were derived by
                splitting this one.
            win_par_indices: A list of lists where each sub-list represents one of
                the WindowParameters in the split_win_par ean each item of each
                sub-list represents one of the rectangles or polygons in the
                window parameters.
        """
        if self.user_data is not None:
            for win_par, wp_i in zip(split_win_par, win_par_indices):
                if win_par is None:
                    continue
                u_dict = {}
                for key, val in self.user_data.items():
                    if isinstance(val, (list, tuple)) and len(val) >= len(self):
                        u_dict[key] = [self.user_data[key][j] for j in wp_i]
                    else:
                        u_dict[key] = val
                win_par.user_data = u_dict


class RectangularWindows(_AsymmetricBase):
    """Instructions for several rectangular windows, defined by origin, width and height.

    Note that, if these parameters are applied to a base wall that is too short
    or too narrow such that the windows fall outside the boundary of the wall, the
    generated windows will automatically be shortened or excluded. This way, a
    certain pattern of repeating rectangular windows can be encoded in a single
    RectangularWindows instance and applied to multiple Room2D segments.

    Args:
        origins: An array of ladybug_geometry Point2D objects within the plane
            of the wall for the origin of each window. The wall plane is assumed
            to have an origin at the first point of the wall segment and an
            X-axis extending along the length of the segment. The wall plane's
            Y-axis always points upwards.  Therefore, both X and Y values of
            each origin point should be positive.
        widths: An array of positive numbers for the window widths. The length
            of this list must match the length of the origins.
        heights: An array of positive numbers for the window heights. The length
            of this list must match the length of the origins.
        are_doors: An array of booleans that align with the origins and note whether
            each of the geometries represents a door (True) or a window (False).
            If None, it will be assumed that all geometries represent windows and
            they will be translated to Apertures in any resulting Honeybee
            model. (Default: None).

    Properties:
        * origins
        * widths
        * heights
        * are_doors
        * user_data
    """
    __slots__ = ('_origins', '_widths', '_heights', '_are_doors')

    def __init__(self, origins, widths, heights, are_doors=None):
        """Initialize RectangularWindows."""
        _WindowParameterBase.__init__(self)  # add the user_data
        if not isinstance(origins, tuple):
            origins = tuple(origins)
        for point in origins:
            assert isinstance(point, Point2D), \
                'Expected Point2D for window origin. Got {}'.format(type(point))
        self._origins = origins

        self._widths = tuple(float_positive(width, 'window width') for width in widths)
        self._heights = tuple(float_positive(hgt, 'window height') for hgt in heights)

        assert len(self._origins) == len(self._widths) == len(self._heights), \
            'Number of window origins, widths, and heights must match.'
        assert len(self._origins) != 0, \
            'There must be at least one window to use RectangularWindows.'

        if are_doors is None:
            self._are_doors = (False,) * len(origins)
        else:
            if not isinstance(are_doors, tuple):
                are_doors = tuple(are_doors)
            for is_dr in are_doors:
                assert isinstance(is_dr, bool), 'Expected booleans for ' \
                    'RectangularWindows.are_doors. Got {}'.format(type(is_dr))
            assert len(are_doors) == len(origins), \
                'Length of RectangularWindows.are_doors ({}) does not match length ' \
                'of RectangularWindows.origins ({}).'.format(
                    len(are_doors), len(origins))
            self._are_doors = are_doors

    @property
    def origins(self):
        """Get an array of Point2Ds within the wall plane for the origin of each window.
        """
        return self._origins

    @property
    def widths(self):
        """Get an array of numbers for the window widths."""
        return self._widths

    @property
    def heights(self):
        """Get an array of numbers for the window heights."""
        return self._heights

    @property
    def are_doors(self):
        """Get an array of booleans that note whether each geometry is a door."""
        return self._are_doors

    def area_from_segment(self, segment, floor_to_ceiling_height):
        """Get the window area generated by these parameters from a LineSegment3D.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        max_width = segment.length
        max_height = floor_to_ceiling_height

        areas = []
        for o, width, height in zip(self.origins, self.widths, self.heights):
            final_width = max_width - o.x if width + o.x > max_width else width
            final_height = max_height - o.y if height + o.y > max_height else height
            if final_height > 0 and final_height > 0:  # inside wall boundary
                areas.append(final_width * final_height)

        return sum(areas)

    def check_window_overlaps(self, tolerance=0.01):
        """Check whether any windows overlap with one another.

        Args:
            tolerance: The minimum distance that two windows must overlap in order
                for them to be considered overlapping and invalid. (Default: 0.01,
                suitable for objects in meters).

        Returns:
            A string with the message. Will be an empty string if valid.
        """
        # group the rectangular parameters to gether
        zip_obj = zip(self.origins, self.widths, self.heights)
        rect_par = [(o, w, h) for o, w, h in zip_obj]
        # loop through the polygons and check to see if it overlaps with the others
        grouped_rects = [[rect_par[0]]]
        for rect in rect_par[1:]:
            group_found = False
            for rect_group in grouped_rects:
                for oth_rect in rect_group:
                    if self._rectangles_overlap(rect, oth_rect, tolerance):
                        rect_group.append(rect)
                        group_found = True
                        break
                if group_found:
                    break
            if not group_found:  # the polygon does not overlap with any of the others
                grouped_rects.append([rect])  # make a new group for the polygon
        # report any polygons that overlap
        if not all(len(g) == 1 for g in grouped_rects):
            base_msg = '({} windows overlap one another)'
            all_msg = []
            for r_group in grouped_rects:
                if len(r_group) != 1:
                    all_msg.append(base_msg.format(len(r_group)))
            return ' '.join(all_msg)
        return ''

    def check_valid_for_segment(self, segment, floor_to_ceiling_height):
        """Check that these window parameters are valid for a given LineSegment3D.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.

        Returns:
            A string with the message. Will be an empty string if valid.
        """
        total_area = segment.length * floor_to_ceiling_height
        win_area = self.area_from_segment(segment, floor_to_ceiling_height)
        if win_area >= total_area:
            return 'Total area of windows [{}] is greater than the area of the ' \
                'parent wall [{}].'.format(win_area, total_area)
        return ''

    def to_rectangular_windows(self, segment, floor_to_ceiling_height):
        """Returns the class instance. Provided here for consistency with other classes.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        return self
    
    def to_detailed_windows(self):
        """Get a version of these WindowParameters as DetailedWindows."""
        polygons = []
        for o, w, h in zip(self.origins, self.widths, self.heights):
            poly_pts = (o, Point2D(o.x + w, o.y), Point2D(o.x + w, o.y + h),
                        Point2D(o.x, o.y + h))
            polygons.append(Polygon2D(poly_pts))
        return DetailedWindows(polygons, self.are_doors)

    def add_window_to_face(self, face, tolerance=0.01):
        """Add Apertures to a Honeybee Face using these Window Parameters.

        Args:
            face: A honeybee-core Face object.
            tolerance: Optional tolerance value. Default: 0.01, suitable for
                objects in meters.
        """
        # collect the global properties of the face that set limits on apertures
        wall_plane = face.geometry.plane
        wall_plane = wall_plane.rotate(wall_plane.n, math.pi, wall_plane.o)
        width_seg = LineSegment3D.from_end_points(face.geometry[0], face.geometry[1])
        h_vec, b_pt = Vector3D(0, 0, face.max.z - face.min.z), face.geometry[1]
        height_seg = LineSegment3D.from_end_points(b_pt, b_pt.move(h_vec))
        max_width = width_seg.length - tolerance
        max_height = height_seg.length - tolerance

        # loop through each window and create its geometry
        sub_faces = []
        zip_obj = zip(self.origins, self.widths, self.heights, self.are_doors)
        for i, (o, wid, hgt, isd) in enumerate(zip_obj):
            final_width = max_width - o.x if wid + o.x > max_width else wid
            final_height = max_height - o.y if hgt + o.y > max_height else hgt
            if final_height > 0 and final_height > 0:  # inside wall boundary
                base_plane = Plane(wall_plane.n, wall_plane.xy_to_xyz(o), wall_plane.x)
                s_geo = Face3D.from_rectangle(final_width, final_height, base_plane)
                if isd:
                    sub_f = Door('{}_Door{}'.format(face.identifier, i + 1), s_geo)
                    face.add_door(sub_f)
                else:
                    sub_f = Aperture('{}_Glz{}'.format(face.identifier, i + 1), s_geo)
                    face.add_aperture(sub_f)
                sub_faces.append(sub_f)
                # if the Aperture is interior, set adjacent boundary condition
                if isinstance(face._boundary_condition, Surface):
                    ids = face._boundary_condition.boundary_condition_objects
                    sf_type = 'Door' if isd else 'Glz'
                    adj_sf_id = '{}_{}{}'.format(ids[0], sf_type, i + 1)
                    final_ids = (adj_sf_id,) + ids
                    sub_f.boundary_condition = Surface(final_ids, True)
        self._apply_user_data_to_honeybee(sub_faces)

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

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

        Args:
            factor: A number representing how much the object should be scaled.
        """
        new_w = RectangularWindows(
            tuple(Point2D(pt.x * factor, pt.y * factor) for pt in self.origins),
            tuple(width * factor for width in self.widths),
            tuple(height * factor for height in self.heights),
            self.are_doors)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def flip(self, seg_length):
        """Flip the direction of the windows along a segment.

        This is needed since windows can exist asymmetrically across the wall
        segment and operations like reflecting the Room2D across a plane will
        require the window parameters to be flipped to remain in the same place.

        Args:
            seg_length: The length of the segment along which the parameters are
                being flipped.
        """
        new_origins = []
        new_widths = []
        new_heights = []
        new_are_doors = []
        zip_obj = zip(self.origins, self.widths, self.heights, self.are_doors)
        for o, width, height, is_dr in zip_obj:
            new_x = seg_length - o.x - width
            if new_x > 0:  # fully within the wall boundary
                new_origins.append(Point2D(new_x, o.y))
                new_widths.append(width)
                new_heights.append(height)
                new_are_doors.append(is_dr)
            elif new_x + width > seg_length * 0.001:  # partially within the boundary
                new_widths.append(width + new_x - (seg_length * 0.001))
                new_x = seg_length * 0.001
                new_origins.append(Point2D(new_x, o.y))
                new_heights.append(height)
                new_are_doors.append(is_dr)
        new_w = RectangularWindows(new_origins, new_widths, new_heights, new_are_doors)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def split(self, segments, tolerance=0.01):
        """Split RectangularWindows parameters across a list of ordered segments.

        Args:
            segments: The segments to which the window parameters are being
                split across. These should be in order as they appear on the
                parent Room2D.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        new_win_pars, win_par_is = [], []
        rel_pt, rel_w, rel_h = self.origins, self.widths, self.heights
        rel_d, rel_i = self.are_doors, list(range(len(self.origins)))
        for segment in segments:
            # loop through the vertices and adjust them to the max width
            win_par_i, out_i = [], []
            seg_len = segment.length
            max_width = seg_len - tolerance
            new_pts, new_w, new_h, new_d = [], [], [], []
            out_pts, out_w, out_h, out_d = [], [], [], []
            for pt, w, h, d, i in zip(rel_pt, rel_w, rel_h, rel_d, rel_i):
                x_val = pt.x
                if x_val >= max_width:  # completely outside of the segment
                    out_pts.append(Point2D(x_val - seg_len, pt.y))
                    out_w.append(w)
                    out_h.append(h)
                    out_d.append(d)
                    out_i.append(i)
                elif x_val + w >= max_width:  # split by segment
                    split_dist = max_width - x_val
                    new_pts.append(Point2D(x_val, pt.y))
                    new_w.append(split_dist - tolerance)
                    new_h.append(h)
                    new_d.append(d)
                    win_par_i.append(i)
                    out_pts.append(Point2D(tolerance, pt.y))
                    out_w.append(w - split_dist - (2 * tolerance))
                    out_h.append(h)
                    out_d.append(d)
                    out_i.append(i)
                else:  # completely inside segment
                    new_pts.append(pt)
                    new_w.append(w)
                    new_h.append(h)
                    new_d.append(d)
                    win_par_i.append(i)

            # build the final window parameters from the adjusted windows
            if len(new_pts) != 0:
                new_win_pars.append(RectangularWindows(new_pts, new_w, new_h, new_d))
            else:
                new_win_pars.append(None)
            win_par_is.append(win_par_i)
            # shift all windows to be relevant for the next segment
            rel_pt, rel_w, rel_h, rel_d, rel_i = out_pts, out_w, out_h, out_d, out_i

        # apply the user_data to the split window parameters
        self._split_user_data(new_win_pars, win_par_is)
        return new_win_pars

    def trim(self, original_segment, sub_segment, tolerance=0.01):
        """Trim RectangularWindows for a sub segment given the original segment.

        Args:
            original_segment: The original LineSegment3D to which the window
                parameters are assigned.
            sub_segment: A LineSegment3D that is a sub-segment of the original_segment,
                which will be used to trim the window parameters to fit this segment.
                Note that this sub_segment should have the same orientation as
                the original segment.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        # shift all origins to be relevant for the sub segment
        rel_pt = self.origins
        shift_dist = original_segment.p1.distance_to_point(sub_segment.p1)
        if shift_dist > tolerance:
            rel_pt = []
            for pt in self.origins:
                rel_pt.append(Point2D(pt.x - shift_dist, pt.y))
        # loop through the rectangles and trim them for the segment
        max_width = sub_segment.length - tolerance
        new_pts, new_w, new_h, new_d = [], [], [], []
        for pt, w, h, d in zip(rel_pt, self.widths, self.heights, self.are_doors):
            x_val = pt.x
            if x_val >= max_width or x_val + w < tolerance:  
                continue  # completely outside of the segment
            if x_val < tolerance:  # split by segment on left
                w = tolerance - x_val
                x_val = tolerance
            if x_val + w >= max_width:  # split by segment on right
                split_dist = max_width - x_val
                new_pts.append(Point2D(x_val, pt.y))
                new_w.append(split_dist - tolerance)
                new_h.append(h)
                new_d.append(d)
            else:  # completely inside segment
                new_pts.append(pt)
                new_w.append(w)
                new_h.append(h)
                new_d.append(d)
        # build the final window parameters from the adjusted windows
        if len(new_pts) == 0:
            return None
        return RectangularWindows(new_pts, new_w, new_h, new_d)

    @staticmethod
    def merge(window_parameters, segments, floor_to_ceiling_height):
        """Merge RectangularWindows parameters together using their assigned segments.

        Args:
            window_parameters: A list of WindowParameters to be merged.
            segments: The segments to which the window parameters are assigned.
                These should be in order as they appear on the parent Room2D.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segments belong.
        """
        new_w_par = _WindowParameterBase.merge_to_rectangular(
            window_parameters, segments, floor_to_ceiling_height)
        new_w_par._merge_user_data(window_parameters)
        return new_w_par

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

        .. code-block:: python

            {
            "type": "RectangularWindows",
            "origins": [(1, 1), (3, 0.5)],  # array of (x, y) floats in wall plane
            "widths": [1, 3],  # array of floats for window widths
            "heights": [1, 2.5],  # array of floats for window heights
            "are_doors": [False, True]  # array of booleans for whether it's a door
            }
        """
        assert data['type'] == 'RectangularWindows', \
            'Expected RectangularWindows. Got {}.'.format(data['type'])
        are_doors = data['are_doors'] if 'are_doors' in data else None
        new_w_par = cls(tuple(Point2D.from_array(pt) for pt in data['origins']),
                        data['widths'], data['heights'], are_doors)
        if 'user_data' in data and data['user_data'] is not None:
            new_w_par.user_data = data['user_data']
        return new_w_par

    def to_dict(self):
        """Get RectangularWindows as a dictionary."""
        base = {
            'type': 'RectangularWindows',
            'origins': [pt.to_array() for pt in self.origins],
            'widths': self.widths,
            'heights': self.heights,
            "are_doors": self.are_doors
        }
        if self.user_data is not None:
            base['user_data'] = self.user_data
        return base

    @staticmethod
    def _rectangles_overlap(rect_par_1, rect_par_2, tolerance):
        """Test if two rectangles overlap within a tolerance."""
        min_pt1, w1, h1 = rect_par_1
        max_pt1 = Point2D(min_pt1.x + w1, min_pt1.y + h1)
        min_pt2, w2, h2 = rect_par_2
        max_pt2 = Point2D(min_pt2.x + w2, min_pt2.y + h2)
        if min_pt2.x > max_pt1.x - tolerance or min_pt1.x > max_pt2.x - tolerance:
            return False
        if min_pt2.y > max_pt1.y - tolerance or min_pt1.y > max_pt2.y - tolerance:
            return False
        return True

    def __copy__(self):
        new_w = RectangularWindows(
            self.origins, self.widths, self.heights, self.are_doors)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def __key(self):
        """A tuple based on the object properties, useful for hashing."""
        return (hash(self.origins), hash(self.widths), hash(self.heights),
                hash(self.are_doors))

    def __hash__(self):
        return hash(self.__key())

    def __eq__(self, other):
        return isinstance(other, RectangularWindows) and \
            len(self._origins) == len(other._origins)

    def __ne__(self, other):
        return not self.__eq__(other)

    def __len__(self):
        return len(self.origins)

    def __repr__(self):
        return 'RectangularWindows: [{} windows]'.format(len(self.origins))


class DetailedWindows(_AsymmetricBase):
    """Instructions for detailed windows, defined by 2D Polygons (lists of 2D vertices).

    Note that these parameters are intended to represent windows that are specific
    to a particular segment and, unlike the other WindowParameters, this class
    performs no automatic checks to ensure that the windows lie within the
    boundary of the wall they have been assigned to.

    Args:
        polygons: An array of ladybug_geometry Polygon2D objects within the plane
            of the wall with one polygon for each window. The wall plane is
            assumed to have an origin at the first point of the wall segment
            and an X-axis extending along the length of the segment. The wall
            plane's Y-axis always points upwards.  Therefore, both X and Y
            values of each point in the polygon should always be positive.
        are_doors: An array of booleans that align with the polygons and note
            whether each of the polygons represents a door (True) or a window (False).
            If None, it will be assumed that all polygons represent windows and
            they will be translated to Apertures in any resulting Honeybee
            model. (Default: None).

    Properties:
        * polygons
        * are_doors
        * user_data

    Usage:
        Note that, if you are starting from 3D vertices of windows, you can
        use this class to represent them. The DetailedWindows.from_face3ds is the
        simplest way to do this. Otherwise, below is some sample code to convert
        from vertices in the same 3D space as a vertical wall to vertices in the 2D
        plane of the wall (as this class interprets it).

        In the code, 'seg_p1' is the first point of a given wall segment and
        is assumed to be the origin of the wall plane. 'seg_p2' is the second
        point of the wall segment, and 'vertex' is a given vertex of the
        window that you want to translate from 3D coordinates into 2D. All
        input points are presented as arrays of 3 floats and the output is
        an array of 2 (x, y) coordinates.

    .. code-block:: python

        def dot_product(v1, v2):
            return v1[0] * v2[0] + v1[1] * v2[1] + v1[2] * v2[3]

        def normalize(v):
            d = (v[0] ** 2 + v[1] ** 2 + v[2] ** 2) ** 0.5
            return (v[0] / d, v[1] / d, v[2] / d)

        def xyz_to_xy(seg_p1, seg_p2, vertex):
            diff = (vertex[0] - seg_p1[0], vertex[1] - seg_p1[1], vertex[2] - seg_p1[2])
            axis = (seg_p2[0] - seg_p1[0], seg_p2[1] - seg_p1[1], seg_p2[2] - seg_p1[2])
            plane_x = normalize(axis)
            plane_y = (0, 0, 1)
            return (dot_product(plane_x , diff), dot_product(plane_y, diff))
    """
    __slots__ = ('_polygons', '_are_doors')

    def __init__(self, polygons, are_doors=None):
        """Initialize DetailedWindows."""
        _WindowParameterBase.__init__(self)  # add the user_data
        if not isinstance(polygons, tuple):
            polygons = tuple(polygons)
        for polygon in polygons:
            assert isinstance(polygon, Polygon2D), \
                'Expected Polygon2D for window polygon. Got {}'.format(type(polygon))
        assert len(polygons) != 0, \
            'There must be at least one polygon to use DetailedWindows.'
        self._polygons = polygons
        if are_doors is None:
            self._are_doors = (False,) * len(polygons)
        else:
            if not isinstance(are_doors, tuple):
                are_doors = tuple(are_doors)
            for is_dr in are_doors:
                assert isinstance(is_dr, bool), 'Expected booleans for ' \
                    'DetailedWindow.are_doors. Got {}'.format(type(is_dr))
            assert len(are_doors) == len(polygons), \
                'Length of DetailedWindow.are_doors ({}) does not match the length ' \
                'of DetailedWindow.polygons ({}).'.format(len(are_doors), len(polygons))
            self._are_doors = are_doors

    @classmethod
    def from_face3ds(cls, face3ds, segment, are_doors=None):
        """Create DetailedWindows from Face3Ds and a segment they are assigned to.

        Args:
            face3ds: A list of Face3D objects for the detailed windows.
            segment: A LineSegment3D that sets the plane in which 3D vertices
                are located.
            are_doors: An array of booleans that align with the face3ds and note whether
                each of the polygons represents a door (True) or a window (False).
                If None, it will be assumed that all polygons represent windows and
                they will be translated to Apertures in any resulting Honeybee
                model. (Default: None).
        """
        plane = Plane(Vector3D(segment.v.y, -segment.v.x, 0), segment.p, segment.v)
        pt3d = tuple(tuple(pt for pt in face.vertices) for face in face3ds)
        return cls(
            tuple(Polygon2D(tuple(plane.xyz_to_xy(pt) for pt in poly))
                  for poly in pt3d),
            are_doors
        )

    @property
    def polygons(self):
        """Get an array of Polygon2Ds with one polygon for each window."""
        return self._polygons

    @property
    def are_doors(self):
        """Get an array of booleans that note whether each polygon is a door."""
        return self._are_doors

    def is_flipped_equivalent(self, other, segment, tolerance=0.01):
        """Check if this WindowParameter is equal to another when flipped.

        This is useful to know if the window parameters will be valid when translated
        to Honeybee when they are adjacent to the other.

        Args:
            other: Another WindowParameter object for which flipped equivalency
                will be tested.
            segment: A LineSegment3D to which these parameters are applied.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        # first check some fundamental things
        if not isinstance(other, DetailedWindows) or \
                len(self._polygons) != len(other._polygons):
            return False
        # then, check that the polygons actually match
        other_flip = other.flip(segment.length)
        found_adjacencies = 0
        for poly_1 in self._polygons:
            for poly_2 in other_flip._polygons:
                if poly_1.center.distance_to_point(poly_2.center) <= tolerance:
                    found_adjacencies += 1
                    break
        if len(self._polygons) == found_adjacencies:
            return True
        return False

    def area_from_segment(self, segment, floor_to_ceiling_height):
        """Get the window area generated by these parameters from a LineSegment3D.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        return sum(polygon.area for polygon in self._polygons)

    def check_window_overlaps(self, tolerance=0.01):
        """Check whether any polygons overlap with one another.

        Args:
            tolerance: The minimum distance that two polygons must overlap in order
                for them to be considered overlapping and invalid. (Default: 0.01,
                suitable for objects in meters).

        Returns:
            A string with the message. Will be an empty string if valid.
        """
        # group the polygons according to their overlaps
        grouped_polys = Polygon2D.group_by_overlap(self.polygons, tolerance)
        # report any polygons that overlap
        if not all(len(g) == 1 for g in grouped_polys):
            base_msg = '({} windows overlap one another)'
            all_msg = []
            for p_group in grouped_polys:
                if len(p_group) != 1:
                    all_msg.append(base_msg.format(len(p_group)))
            return ' '.join(all_msg)
        return ''

    def check_self_intersecting(self, tolerance=0.01):
        """Check whether any polygons in these window parameters are self intersecting.

        Args:
            tolerance: The minimum distance between a vertex coordinates where
                they are considered equivalent. (Default: 0.01, suitable
                for objects in meters).

        Returns:
            A string with the message. Will be an empty string if valid.
        """
        self_int_i = []
        for i, polygon in enumerate(self.polygons):
            if polygon.is_self_intersecting:
                new_geo = polygon.remove_colinear_vertices(tolerance)
                if new_geo.is_self_intersecting:
                    self_int_i.append(str(i))
        if len(self_int_i) != 0:
            return 'Window polygons with the following indices are ' \
                'self-intersecting: ({})'.format(' '.join(self_int_i))
        return ''

    def check_valid_for_segment(self, segment, floor_to_ceiling_height):
        """Check that these window parameters are valid for a given LineSegment3D.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.

        Returns:
            A string with the message. Will be an empty string if valid.
        """
        # first check that the total window area isn't larger than the wall
        max_width = segment.length
        total_area = max_width * floor_to_ceiling_height
        win_area = self.area_from_segment(segment, floor_to_ceiling_height)
        if win_area >= total_area:
            return 'Total area of windows [{}] is greater than the area of the ' \
                'parent wall [{}].'.format(win_area, total_area)
        # next, check to be sure that no window is out of the wall boundary
        msg_template = 'Vertex 2D {} coordinate [{}] is outside the range allowed ' \
            'by the parent wall segment.'
        for p_gon in self.polygons:
            min_pt, max_pt = p_gon.min, p_gon.max
            if min_pt.x <= 0:
                return msg_template.format('X', min_pt.x)
            if min_pt.y <= 0:
                return msg_template.format('Y', min_pt.y)
            if max_pt.x >= max_width:
                return msg_template.format('X', max_pt.x)
            if max_pt.y >= floor_to_ceiling_height:
                return msg_template.format('Y', max_pt.y)
        return ''

    def adjust_for_segment(self, segment, floor_to_ceiling_height, tolerance=0.01):
        """Get these parameters with vertices excluded beyond the domain of a given line.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).

        Returns:
            A new DetailedWindows object that fits entirely in the domain of the
            input line segment and floor_to_ceiling_height.
        """
        # compute the maximum width and height
        max_width = segment.length - tolerance
        max_height = floor_to_ceiling_height - tolerance

        # loop through the vertices and adjust them
        new_polygons, new_are_doors, kept_i = [], [], []
        for i, (p_gon, is_dr) in enumerate(zip(self.polygons, self.are_doors)):
            new_verts, verts_moved = [], []
            for vert in p_gon:
                x_val, y_val, v_moved = vert.x, vert.y, False
                if x_val <= tolerance:
                    x_val, v_moved = tolerance, True
                if y_val <= tolerance:
                    y_val, v_moved = tolerance, True
                if x_val >= max_width:
                    x_val, v_moved = max_width, True
                if y_val >= max_height:
                    y_val, v_moved = max_height, True
                new_verts.append(Point2D(x_val, y_val))
                verts_moved.append(v_moved)
            if not all(verts_moved):
                new_polygons.append(Polygon2D(new_verts))
                new_are_doors.append(is_dr)
                kept_i.append(i)

        # return the final window parameters
        new_w_par = None
        if len(new_polygons) != 0:
            new_w_par = DetailedWindows(new_polygons, new_are_doors)

        # update user_data lists if some windows were not added
        if new_w_par is not None and self.user_data is not None:
            clean_u = self.user_data
            if len(new_polygons) != len(self.polygons):
                clean_u = {}
                for key, val in self.user_data.items():
                    if isinstance(val, (list, tuple)) and len(val) >= len(self.polygons):
                        clean_u[key] = [val[j] for j in kept_i]
                    else:
                        clean_u[key] = val
            new_w_par.user_data = clean_u
        return new_w_par

    def to_rectangular_windows(self, segment, floor_to_ceiling_height):
        """Get a version of these WindowParameters as RectangularWindows.

        This will simply translate each window polygon to its own rectangular
        window. For merging windows that touch or overlap one another into
        rectangles, the merge_to_bounding_rectangle method should be used
        before using this method.

        Args:
            segment: A LineSegment3D to which these parameters are applied.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        origins, widths, heights = [], [], []
        for poly in self.polygons:
            min_pt, max_pt = poly.min, poly.max
            origins.append(min_pt)
            widths.append(max_pt.x - min_pt.x)
            heights.append(max_pt.y - min_pt.y)
        new_w = RectangularWindows(origins, widths, heights, self.are_doors)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def add_window_to_face(self, face, tolerance=0.01):
        """Add Apertures to a Honeybee Face using these Window Parameters.

        Args:
            face: A honeybee-core Face object.
            tolerance: Optional tolerance value. Default: 0.01, suitable for
                objects in meters.
        """
        # get the plane of the parent wall
        wall_plane = face.geometry.plane
        wall_plane = wall_plane.rotate(wall_plane.n, math.pi, wall_plane.o)
        width_seg = LineSegment3D.from_end_points(face.geometry[0], face.geometry[1])
        h_vec, b_pt = Vector3D(0, 0, face.max.z - face.min.z), face.geometry[1]
        height_seg = LineSegment3D.from_end_points(b_pt, b_pt.move(h_vec))
        max_width = width_seg.length - tolerance
        max_height = height_seg.length - tolerance

        # automatically clip any geometries outside of the face so they are bounded by it
        clean_polygons, clean_are_doors, kept_i = [], [], []
        for i, (p_gon, is_dr) in enumerate(zip(self.polygons, self.are_doors)):
            new_verts, verts_moved = [], []
            for vert in p_gon:
                x_val, y_val, v_moved = vert.x, vert.y, False
                if x_val <= tolerance:
                    x_val, v_moved = tolerance, True
                if y_val <= tolerance:
                    y_val, v_moved = tolerance, True
                if x_val >= max_width:
                    x_val, v_moved = max_width, True
                if y_val >= max_height:
                    y_val, v_moved = max_height, True
                new_verts.append(Point2D(x_val, y_val))
                verts_moved.append(v_moved)
            if not all(verts_moved):
                clean_polygons.append(new_verts)
                clean_are_doors.append(is_dr)
                kept_i.append(i)

        # update user_data lists if some windows were not added
        clean_u = self.user_data
        if self.user_data is not None:
            if len(clean_polygons) != len(self.polygons):
                clean_u = {}
                for key, val in self.user_data.items():
                    if isinstance(val, (list, tuple)) and len(val) >= len(self.polygons):
                        clean_u[key] = [val[j] for j in kept_i]
                    else:
                        clean_u[key] = val

        # loop through each window and create its geometry
        sub_faces = []
        for i, (polygon, isd) in enumerate(zip(clean_polygons, clean_are_doors)):
            pt3d = tuple(wall_plane.xy_to_xyz(pt) for pt in polygon)
            s_geo = Face3D(pt3d)
            if isd:
                sub_f = Door('{}_Door{}'.format(face.identifier, i + 1), s_geo)
                face.add_door(sub_f)
            else:
                sub_f = Aperture('{}_Glz{}'.format(face.identifier, i + 1), s_geo)
                face.add_aperture(sub_f)
            sub_faces.append(sub_f)
            # if the Aperture is interior, set adjacent boundary condition
            if isinstance(face._boundary_condition, Surface):
                ids = face._boundary_condition.boundary_condition_objects
                sf_type = 'Door' if isd else 'Glz'
                adj_sf_id = '{}_{}{}'.format(ids[0], sf_type, i + 1)
                final_ids = (adj_sf_id,) + ids
                sub_f.boundary_condition = Surface(final_ids, True)
        self._apply_user_data_to_honeybee(sub_faces, clean_u)

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

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

        Args:
            factor: A number representing how much the object should be scaled.
        """
        new_w = DetailedWindows(
            tuple(polygon.scale(factor) for polygon in self.polygons), self.are_doors)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def flip(self, seg_length):
        """Flip the direction of the windows along a segment.

        This is needed since windows can exist asymmetrically across the wall
        segment and operations like reflecting the Room2D across a plane will
        require the window parameters to be flipped to remain in the same place.

        Args:
            seg_length: The length of the segment along which the parameters are
                being flipped.
        """
        # set values derived from the property of the segment
        normal = Vector2D(1, 0)
        origin = Point2D(seg_length / 2, 0)

        # loop through the polygons and reflect them across the mid plane of the wall
        new_polygons = []
        for polygon in self.polygons:
            new_verts = tuple(pt.reflect(normal, origin) for pt in polygon.vertices)
            new_polygons.append(Polygon2D(tuple(reversed(new_verts))))
        new_w = DetailedWindows(new_polygons, self.are_doors)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def union_overlaps(self, tolerance=0.01):
        """Union any window polygons that overlap with one another.

        Args:
            tolerance: The minimum distance that two polygons must overlap in order
                for them to be considered overlapping. (Default: 0.01,
                suitable for objects in meters).
        """
        # group the polygons by their overlap
        grouped_polys = Polygon2D.group_by_overlap(self.polygons, tolerance)
        # union any of the polygons that overlap
        if not all(len(g) == 1 for g in grouped_polys):
            new_polys = []
            for p_group in grouped_polys:
                if len(p_group) == 1:
                    new_polys.append(p_group[0])
                else:
                    union_poly = Polygon2D.boolean_union_all(p_group, tolerance)
                    for new_poly in union_poly:
                        new_polys.append(new_poly.remove_colinear_vertices(tolerance))
            self._reassign_are_doors(new_polys)
            self._polygons = tuple(new_polys)

    def merge_and_simplify(self, max_separation, tolerance=0.01):
        """Merge window polygons that are close to one another into a single polygon.

        This can be used to create a simpler set of windows that is easier to
        edit and is in the same location as the original windows.

        Args:
            max_separation: A number for the maximum distance between window polygons
                at which point they will be merged into a single geometry. Typically,
                this max_separation should be set to a value that is slightly larger
                than the window frame. Setting this equal to the tolerance will
                simply join neighboring windows together.
            tolerance: The maximum difference between point values for them to be
                considered distinct. (Default: 0.01, suitable for objects in meters).
        """
        # gather a clean version of the polygons with colinear vertices removed
        clean_polys = []
        for poly in self.polygons:
            try:
                clean_polys.append(poly.remove_colinear_vertices(tolerance))
            except AssertionError:  # degenerate geometry to ignore
                pass
        # join the polygons together
        if max_separation <= tolerance:
            new_polys = Polygon2D.joined_intersected_boundary(
                clean_polys, tolerance)
        else:
            new_polys = Polygon2D.gap_crossing_boundary(
                clean_polys, max_separation, tolerance)
        self._reassign_are_doors(new_polys)
        self._polygons = tuple(new_polys)

    def merge_to_bounding_rectangle(self, tolerance=0.01):
        """Merge window polygons that touch or overlap with one another to a rectangle.

        Args:
            tolerance: The minimum distance from the edge of a neighboring polygon
                at which a point is considered to touch that polygon. (Default: 0.01,
                suitable for objects in meters).
        """
        # group the polygons by their overlap
        grouped_polys = Polygon2D.group_by_touching(self.polygons, tolerance)
        # union any of the polygons that overlap
        if not all(len(g) == 1 for g in grouped_polys):
            new_polys = []
            for p_group in grouped_polys:
                if len(p_group) == 1:
                    new_polys.append(p_group[0])
                else:
                    min_pt, max_pt = bounding_rectangle(p_group)
                    rect_verts = (
                        min_pt, Point2D(max_pt.x, min_pt.y),
                        max_pt, Point2D(min_pt.x, max_pt.y))
                    rect_poly = Polygon2D(rect_verts)
                    new_polys.append(rect_poly)
            self._reassign_are_doors(new_polys)
            self._polygons = tuple(new_polys)

    def split(self, segments, tolerance=0.01):
        """Split DetailedWindows parameters across a list of ordered segments.

        Args:
            segments: The segments to which the window parameters are being
                split across. These should be in order as they appear on the
                parent Room2D.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        new_win_pars, win_par_is = [], []
        rel_polygons, rel_dr = self.polygons, self.are_doors
        rel_i = list(range(len(self.polygons)))
        for segment in segments:
            # loop through the vertices and adjust them to the max width
            max_width = segment.length - tolerance
            win_par_i, out_i = [], []
            new_polygons, new_dr, out_polygons, out_dr = [], [], [], []
            for p_gon, is_dr, i in zip(rel_polygons, rel_dr, rel_i):
                new_verts, verts_moved = [], []
                for vert in p_gon:
                    x_val, v_moved = vert.x, False
                    if x_val < tolerance:
                        x_val, v_moved = tolerance, True
                    if x_val >= max_width:
                        x_val, v_moved = max_width, True
                    new_verts.append(Point2D(x_val, vert.y))
                    verts_moved.append(v_moved)
                if not all(verts_moved):
                    new_polygons.append(Polygon2D(new_verts))
                    win_par_i.append(i)
                    new_dr.append(is_dr)
                    if True in verts_moved:  # outside of the segment
                        out_polygons.append(p_gon)
                        out_dr.append(is_dr)
                        out_i.append(i)
                else:
                    out_polygons.append(p_gon)
                    out_dr.append(is_dr)
                    out_i.append(i)
            # build the final window parameters from the adjusted polygons
            if len(new_polygons) != 0:
                new_win_pars.append(DetailedWindows(new_polygons, new_dr))
            else:
                new_win_pars.append(None)
            win_par_is.append(win_par_i)
            # shift all polygons to be relevant for the next segment
            shift_dist = segment.length
            rel_polygons = []
            for p_gon in out_polygons:
                new_v = [Point2D(p.x - shift_dist, p.y) for p in p_gon]
                rel_polygons.append(Polygon2D(new_v))
            rel_dr = out_dr
            rel_i = out_i

        # apply the user_data to the split window parameters
        self._split_user_data(new_win_pars, win_par_is)
        return new_win_pars

    def trim(self, original_segment, sub_segment, tolerance=0.01):
        """Trim DetailedWindows for a sub segment given the original segment.

        Args:
            original_segment: The original LineSegment3D to which the window
                parameters are assigned.
            sub_segment: A LineSegment3D that is a sub-segment of the original_segment,
                which will be used to trim the window parameters to fit this segment.
                Note that this sub_segment should have the same orientation as
                the original segment.
            tolerance: The minimum distance between a vertex and the edge of the
                wall segment that is considered not touching. (Default: 0.01, suitable
                for objects in meters).
        """
        # shift all polygons to be relevant for the sub segment
        rel_polygons = self.polygons
        shift_dist = original_segment.p1.distance_to_point(sub_segment.p1)
        if shift_dist > tolerance:
            rel_polygons = []
            for p_gon in self.polygons:
                new_v = [Point2D(p.x - shift_dist, p.y) for p in p_gon]
                rel_polygons.append(Polygon2D(new_v))
        # loop through the polygon vertices and trim them for the segment
        max_width = sub_segment.length - tolerance
        new_polygons, new_dr, out_polygons, out_dr = [], [], [], []
        for p_gon, is_dr in zip(rel_polygons, self.are_doors):
            new_verts, verts_moved = [], []
            for vert in p_gon:
                x_val, v_moved = vert.x, False
                if x_val < tolerance:
                    x_val, v_moved = tolerance, True
                if x_val >= max_width:
                    x_val, v_moved = max_width, True
                new_verts.append(Point2D(x_val, vert.y))
                verts_moved.append(v_moved)
            if not all(verts_moved):
                new_polygons.append(Polygon2D(new_verts))
                new_dr.append(is_dr)
                if True in verts_moved:  # outside of the segment
                    out_polygons.append(p_gon)
                    out_dr.append(is_dr)
            else:
                out_polygons.append(p_gon)
                out_dr.append(is_dr)
        # build the final window parameters from the adjusted polygons
        if len(new_polygons) == 0:
            return None
        return DetailedWindows(new_polygons, new_dr)

    @staticmethod
    def merge(window_parameters, segments, floor_to_ceiling_height):
        """Merge DetailedWindows parameters together using their assigned segments.

        Args:
            window_parameters: A list of WindowParameters to be merged.
            segments: The segments to which the window parameters are assigned.
                These should be in order as they appear on the parent Room2D.
            floor_to_ceiling_height: The floor-to-ceiling height of the Room2D
                to which the segment belongs.
        """
        base_x = 0
        polygons, are_doors = [], []
        for wp, s in zip(window_parameters, segments):
            if wp is not None:
                if isinstance(wp, DetailedWindows):
                    for p_gon, is_dr in zip(wp.polygons, wp.are_doors):
                        new_pts = [Point2D(pt.x + base_x, pt.y) for pt in p_gon.vertices]
                        polygons.append(Polygon2D(new_pts))
                        are_doors.append(is_dr)
                else:  # not all windows are of the same type; convert all to rectangular
                    return _WindowParameterBase.merge_to_rectangular(
                        window_parameters, segments, floor_to_ceiling_height)
            base_x += s.length
        if len(polygons) == 0:  # all of the input window parameters were None
            return None
        new_w_par = DetailedWindows(polygons, are_doors)
        new_w_par._merge_user_data(window_parameters)
        return new_w_par

    @classmethod
    def from_dict(cls, data, segment=None):
        """Create DetailedWindows from a dictionary.

        Args:
            data: A dictionary in the format below. The vertices of the "polygons"
                can either contain 2 values (indicating they are vertices within
                the plane of a parent wall segment) or they can contain 3 values
                (indicating they are 3D world coordinates). If 3 values are used,
                a segment must be specified below to convert them to the 2D format.
            segment: A LineSegment3D that sets the plane in which 3D vertices
                are located.

        .. code-block:: python

            {
            "type": "DetailedWindows",
            "polygons": [((0.5, 0.5), (2, 0.5), (2, 2), (0.5, 2)),
                         ((3, 1), (4, 1), (4, 2))],
            "are_doors": [False]
            }
        """
        assert data['type'] == 'DetailedWindows', \
            'Expected DetailedWindows dictionary. Got {}.'.format(data['type'])
        are_doors = data['are_doors'] if 'are_doors' in data else None
        if len(data['polygons'][0][0]) == 2:
            new_w_par = cls(
                tuple(Polygon2D(tuple(Point2D.from_array(pt) for pt in poly))
                      for poly in data['polygons']),
                are_doors
            )
        else:
            assert segment is not None, 'Segment must be specified when using 3D ' \
                'vertices for DetailedWindows.'
            plane = Plane(Vector3D(segment.v.y, -segment.v.x, 0), segment.p, segment.v)
            pt3d = tuple(tuple(Point3D.from_array(pt) for pt in poly)
                         for poly in data['polygons'])
            new_w_par = cls(
                tuple(Polygon2D(tuple(plane.xyz_to_xy(pt) for pt in poly))
                      for poly in pt3d),
                are_doors
            )
        if 'user_data' in data and data['user_data'] is not None:
            new_w_par.user_data = data['user_data']
        return new_w_par

    def to_dict(self):
        """Get DetailedWindows as a dictionary."""
        base = {
            'type': 'DetailedWindows',
            'polygons': [[pt.to_array() for pt in poly] for poly in self.polygons],
            'are_doors': self.are_doors
        }
        if self.user_data is not None:
            base['user_data'] = self.user_data
        return base

    @staticmethod
    def is_face3d_in_segment_plane(
            face3d, segment, height, tolerance=0.01, angle_tolerance=1):
        """Check if a given Face3D is in the plane and range of a LineSegment3D.

        Args:
            face3d: A list of Face3D objects for the detailed windows.
            segment: A LineSegment3D that sets the plane in which 3D vertices
                are located.
            height: A number for the height of the wall formed by extruding the segment.
            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.
            angle_tolerance: The max angle in degrees that the plane normals can
                differ from one another in order for them to be considered coplanar.
                Default: 1 degree.

        Returns:
            True if the face3d is in the plane and range of the segment. False
            if it is not.
        """
        plane = Plane(Vector3D(segment.v.y, -segment.v.x, 0), segment.p, segment.v)
        # test whether the face3d is coplanar
        if not plane.is_coplanar_tolerance(face3d.plane, tolerance, angle_tolerance):
            return False
        seg_face = Face3D.from_rectangle(segment.length, height, plane)
        return seg_face.is_sub_face(face3d, tolerance, angle_tolerance)

    def _reassign_are_doors(self, new_polys):
        """Reset the are_doors property using a set of new polygons."""
        if len(new_polys) != len(self._polygons):
            if all(not dr for dr in self._are_doors):  # case of no doors
                self._are_doors = (False,) * len(new_polys)
            else:
                new_are_doors = []
                for n_poly in new_polys:
                    for o_poly, is_door in zip(self.polygons, self.are_doors):
                        if n_poly.is_point_inside_bound_rect(o_poly.center):
                            new_are_doors.append(is_door)
                            break
                    else:
                        new_are_doors.append(False)
                self._are_doors = tuple(new_are_doors)

    def __len__(self):
        return len(self._polygons)

    def __getitem__(self, key):
        return self._polygons[key]

    def __iter__(self):
        return iter(self._polygons)

    def __copy__(self):
        new_w = DetailedWindows(self._polygons, self._are_doors)
        new_w._user_data = None if self.user_data is None else self.user_data.copy()
        return new_w

    def __key(self):
        """A tuple based on the object properties, useful for hashing."""
        return tuple(hash(polygon) for polygon in self._polygons) + self.are_doors

    def __hash__(self):
        return hash(self.__key())

    def __eq__(self, other):
        return isinstance(other, DetailedWindows) and \
            len(self._polygons) == len(other._polygons)

    def __ne__(self, other):
        return not self.__eq__(other)

    def __repr__(self):
        return 'DetailedWindows: [{} windows]'.format(len(self._polygons))
