class IDF(PatchedIDF):
    """Geometry-enabled IDF class, usable in the same way as Eppy's IDF.

    This adds geometry functionality to Eppy's IDF class.

    """

    def intersect_match(self):
        # type: () -> None
        """Intersect all surfaces in the IDF, then set boundary conditions."""
        self.intersect()
        self.match()

    def intersect(self):
        # type: () -> None
        """Intersect all surfaces in the IDF."""
        intersect_idf_surfaces(self)

    def match(self):
        # type: () -> None
        """Set boundary conditions for all surfaces in the IDF."""
        match_idf_surfaces(self)

    def translate_to_origin(self):
        # type: () -> None
        """Move an IDF close to the origin so that it can be viewed in SketchUp."""
        translate_to_origin(self)

    def translate(self, vector):
        # type: (Vector2D) -> None
        """Move the IDF in the direction given by a vector.

        :param vector: A vector to translate by.

        """
        surfaces = self.getsurfaces()
        translate(surfaces, vector)
        subsurfaces = self.getsubsurfaces()
        translate(subsurfaces, vector)
        shadingsurfaces = self.getshadingsurfaces()
        translate(shadingsurfaces, vector)

    def rotate(self, angle, anchor=None):
        # type: (float, Optional[Union[Vector2D, Vector3D]]) -> None
        """Rotate the IDF counterclockwise by the angle given.

        :param angle: Angle (in degrees) to rotate by.
        :param anchor: Point around which to rotate. Default is the centre of the the IDF's bounding box.

        """
        anchor = anchor or self.centroid
        surfaces = self.getsurfaces()
        subsurfaces = self.getsubsurfaces()
        shadingsurfaces = self.getshadingsurfaces()
        self.translate(-anchor)
        rotate(surfaces, angle)
        rotate(subsurfaces, angle)
        rotate(shadingsurfaces, angle)
        self.translate(anchor)

    def scale(self, factor, anchor=None, axes="xy"):
        # type: (float, Optional[Union[Vector2D, Vector3D]], str) -> None
        """Scale the IDF by a scaling factor.

        :param factor: Factor to scale by.
        :param anchor: Point to scale around. Default is the centre of the the IDF's bounding box.
        :param axes: Axes to scale on. Default 'xy'.

        """
        anchor = anchor or self.centroid
        surfaces = self.getsurfaces()
        subsurfaces = self.getsubsurfaces()
        shadingsurfaces = self.getshadingsurfaces()
        self.translate(-anchor)
        scale(surfaces, factor, axes)
        scale(subsurfaces, factor, axes)
        scale(shadingsurfaces, factor, axes)
        self.translate(anchor)

    def set_default_constructions(self):
        # type: () -> None
        set_default_constructions(self)

    def bounding_box(self):
        # type: () -> Polygon2D
        """Calculate the site bounding box.

        :returns: A polygon of the bounding box.

        """
        floors = self.getsurfaces("floor")
        return bounding_box(floors)

    @property
    def centroid(self):
        # type: () -> Vector2D
        """Calculate the centroid of the site bounding box.

        :returns: The centroid of the site bounding box.

        """
        bbox = self.bounding_box()
        return bbox.centroid

    def getsurfaces(self, surface_type=""):
        # type: (str) -> Union[List[EpBunch], Idf_MSequence]
        """Return all surfaces in the IDF.

        :param surface_type: Type of surface to get. Defaults to all.
        :returns: IDF surfaces.

        """
        surfaces = itertools.chain.from_iterable(
            [
                self.idfobjects[key.upper()]
                for key in self.idd_index["ref2names"]["SurfaceNames"]
            ]
        )
        if surface_type:
            surfaces = filter(
                lambda x: x.Surface_Type.lower() == surface_type.lower(), surfaces
            )
        return list(surfaces)

    def getsubsurfaces(self, surface_type=""):
        # type: (str) -> Union[List[EpBunch], Idf_MSequence]
        """Return all subsurfaces in the IDF.

        :param surface_type: Type of surface to get. Defaults to all.
        :returns: IDF surfaces.

        """
        surfaces = itertools.chain.from_iterable(
            [
                self.idfobjects[key.upper()]
                for key in self.idd_index["ref2names"]["SubSurfNames"]
            ]
        )
        if surface_type:
            surfaces = filter(
                lambda x: x.Surface_Type.lower() == surface_type.lower(), surfaces
            )
        return list(surfaces)

    def getshadingsurfaces(self, surface_type=""):
        # type: (str) -> Union[List[EpBunch], Idf_MSequence]
        """Return all subsurfaces in the IDF.

        :param surface_type: Type of surface to get. Defaults to all.
        :returns: IDF surfaces.

        """
        surfaces = itertools.chain.from_iterable(
            [
                self.idfobjects[key.upper()]
                for key in self.idd_index["ref2names"]["AllShadingSurfNames"]
            ]
        )
        if surface_type:
            surfaces = filter(
                lambda x: x.Surface_Type.lower() == surface_type.lower(), surfaces
            )
        return list(surfaces)

    def set_wwr(
        self, wwr=0.2, construction=None, force=False, wwr_map={}, orientation=None
    ):
        # type: (Optional[float], Optional[str], Optional[bool], Optional[dict], Optional[str]) -> None
        """Add strip windows to all external walls.

        Different WWR can be applied to specific wall orientations using the `wwr_map` keyword arg.
        This map is a dict of wwr values, keyed by `wall.azimuth`, which overrides the default passed as `wwr`.

        They can also be applied to walls oriented to a compass point, e.g. north, which will apply to walls which
        have an azimuth within 45 degrees of due north.

        :param wwr: Window to wall ratio in the range 0.0 to 1.0.
        :param construction: Name of a window construction.
        :param force: True to remove all subsurfaces before setting the WWR.
        :param wwr_map: Mapping from wall orientation (azimuth) to WWR, e.g. {180: 0.25, 90: 0.2}.
        :param orientation: One of "north", "east", "south", "west". Walls within 45 degrees will be affected.

        """
        set_wwr(self, wwr, construction, force, wwr_map, orientation)

    def view_model(self, test=False):
        # type: (Optional[bool]) -> None
        """Show a zoomable, rotatable representation of the IDF."""
        view_idf(idf=self, test=test)

    def to_obj(self, fname=None, mtllib=None):
        # type: (Optional[str], Optional[str]) -> None
        """Export an OBJ file representation of the IDF.

        This can be used for viewing in tools which support the .obj format.

        :param fname: A filename for the .obj file. If None we try to base it on IDF.idfname and change the filetype.
        :param mtllib: The name of a .mtl file to be referenced from the .obj file. If None, we use default.mtl.
        """
        if not fname:
            try:
                fname = self.idfname.replace(".idf", ".obj")
            except AttributeError:
                fname = "default.obj"
        export_to_obj(self, fname, mtllib)

    def add_block(self, *args, **kwargs):
        # type: (*Any, **Any) -> None
        """Add a block to the IDF.

        :param name: A name for the block.
        :param coordinates: A list of (x, y) tuples representing the building outline.
        :param height: The height of the block roof above ground level.
        :param num_stories: The total number of stories including basement stories. Default : 1.
        :param below_ground_stories: The number of stories below ground. Default : 0.
        :param below_ground_storey_height: The height of each basement storey. Default : 2.5.
        :param zoning: The zoning pattern of the block. Default : by_storey
        :param perim_depth: Depth of the perimeter zones if the core/perim zoning pattern is requested. Default : 3.0.

        """
        block = Block(*args, **kwargs)
        block.zoning = kwargs.get("zoning", "by_storey")
        if block.zoning == "by_storey":
            zones = [
                Zone("Block %s Storey %i" % (block.name, storey["storey_no"]), storey)
                for storey in block.stories
            ]
        elif block.zoning == "core/perim":
            zones = []
            try:
                for name, coords in core_perim_zone_coordinates(
                    block.coordinates, block.perim_depth
                )[0].items():
                    block = Block(
                        name=name,
                        coordinates=coords,
                        height=block.height,
                        num_stories=block.num_stories,
                    )
                    zones += [
                        Zone(
                            "Block %s Storey %i" % (block.name, storey["storey_no"]),
                            storey,
                        )
                        for storey in block.stories
                    ]
            except NotImplementedError:
                raise ValueError("Perimeter depth is too great")
        else:
            raise ValueError("%s is not a valid zoning rule" % block.zoning)

        for zone in zones:
            self.add_zone(zone)

    def add_shading_block(self, *args, **kwargs):
        # type: (*Any, **Any) -> None
        """Add a shading block to the IDF.

        :param name: A name for the block.
        :param coordinates: A list of (x, y) tuples representing the building outline.
        :param height: The height of the block roof above ground level.
        :param num_stories: The total number of stories including basement stories. Default : 1.
        :param below_ground_stories: The number of stories below ground. Default : 0.
        :param below_ground_storey_height: The height of each basement storey. Default : 2.5.

        """
        block = Block(*args, **kwargs)
        for i, wall in enumerate(block.walls[0], 1):
            if wall.area <= 0:
                continue
            s = self.newidfobject(
                "SHADING:SITE:DETAILED", Name="%s_%s" % (block.name, i)
            )
            try:
                s.setcoords(wall)
            except ZeroDivisionError:
                self.removeidfobject(s)

    def add_zone(self, zone):
        # type: (Zone) -> None
        """Add a zone to the IDF.

        :param zone: A Zone object holding details about the zone.

        """
        try:
            ggr = self.idfobjects["GLOBALGEOMETRYRULES"][
                0
            ]  # type: Optional[Dict[str, Idf_MSequence]]
        except IndexError:
            ggr = None
        # add zone object
        self.newidfobject("ZONE", Name=zone.name)

        for surface_type in zone.__dict__.keys():
            if surface_type == "name":
                continue
            for i, surface_coords in enumerate(zone.__dict__[surface_type], 1):
                if not surface_coords:
                    continue
                name = "{name} {s_type} {num:04d}".format(
                    name=zone.name, s_type=surface_type[:-1].title(), num=i
                )
                s = self.newidfobject(
                    "BUILDINGSURFACE:DETAILED",
                    Name=name,
                    Surface_Type=surface_type[:-1],
                    Zone_Name=zone.name,
                )
                s.setcoords(surface_coords, ggr) def intersect_idf_surfaces(idf):
    # type: (IDF) -> None
    """Intersect all surfaces in an IDF.

    :param idf: The IDF.
    """
    surfaces = idf.getsurfaces() + idf.getshadingsurfaces()
    try:
        ggr = idf.idfobjects["GLOBALGEOMETRYRULES"][0]
    except IndexError:
        ggr = None
    # get all the intersected surfaces
    adjacencies = get_adjacencies(surfaces)
    for surface in adjacencies:
        key, name = surface
        new_surfaces = adjacencies[surface]
        old_obj = idf.getobject(key.upper(), name)
        for i, new_coords in enumerate(new_surfaces, 1):
            new = idf.copyidfobject(old_obj)
            new.Name = "%s_%i" % (name, i)
            set_coords(new, new_coords, ggr)
        idf.removeidfobject(old_obj) def match_idf_surfaces(idf):
    # type: (IDF) -> None
    """Match all surfaces in an IDF.

    :param idf: The IDF.
    """
    surfaces = idf.getsurfaces() + idf.getshadingsurfaces()
    planes = getidfplanes(surfaces)
    matched = {}
    for distance in planes:
        for vector in planes[distance]:
            surfaces = planes[distance][vector]
            for surface in surfaces:
                set_unmatched_surface(surface, vector)
            matches = planes.get(-distance, {}).get(-vector, [])
            for s, m in product(surfaces, matches):
                if almostequal(s.coords, reversed(m.coords)):
                    matched[sorted_tuple(m, s)] = (m, s)

    for key in matched:
        set_matched_surfaces(*matched[key]) class Polygon3D(Clipper3D, Polygon):
    """Three-dimensional polygon."""

    n_dims = 3
    vector_class = Vector3D

    def __eq__(self, other):
        # check they're in the same plane
        if not almostequal(self.normal_vector, other.normal_vector):
            return False
        if not almostequal(self.distance, other.distance):
            return False
        # if they are in the same plane, check they completely overlap in 2D
        return self.project_to_2D() == other.project_to_2D()

    @property
    def zs(self):
        # type: () -> List[float]
        return [pt.z for pt in self.vertices]

    @property
    def normal_vector(self):
        """Unit normal vector perpendicular to the polygon in the outward direction.

        We use Newell's Method since the cross-product of two edge vectors is not valid for concave polygons.
        https://www.opengl.org/wiki/Calculating_a_Surface_Normal#Newell.27s_Method

        """
        n = [0.0, 0.0, 0.0]

        for i, v_curr in enumerate(self.vertices):
            v_next = self.vertices[(i + 1) % len(self.vertices)]
            n[0] += (v_curr.y - v_next.y) * (v_curr.z + v_next.z)
            n[1] += (v_curr.z - v_next.z) * (v_curr.x + v_next.x)
            n[2] += (v_curr.x - v_next.x) * (v_curr.y + v_next.y)

        return Vector3D(*n).normalize()

    @property
    def distance(self):
        # type: () -> np.float64
        """Distance from the origin to the polygon.

        Where v[0] * x + v[1] * y + v[2] * z = a is the equation of the plane containing the polygon (and where v
        is the polygon normal vector).

        :returns: The distance from the origin to the polygon.

        """
        v = self.normal_vector
        pt = self.points_matrix[0]  # arbitrary point in the polygon
        d = np.dot(v, pt)
        return d

    @property
    def projection_axis(self):
        # type: () -> int
        """An axis which will not lead to a degenerate surface.

        :returns: The axis index.

        """
        proj_axis = max(range(3), key=lambda i: abs(self.normal_vector[i]))
        return proj_axis

    @property
    def is_horizontal(self):
        # type: () -> bool
        """Check if polygon is in the xy plane.

        :returns: True if the polygon is in the xy plane, else False.

        """
        return bool(np.array(self.zs).std() < 1e-8)

    def is_clockwise(self, viewpoint):
        # type: (Vector3D) -> np.bool_
        """Check if vertices are ordered clockwise

        This function checks the vertices as seen from the viewpoint.

        :param viewpoint: A point from which to view the polygon.
        :returns: True if vertices are ordered clockwise when observed from the given viewpoint.

        """
        arbitrary_pt = self.vertices[0]
        v = arbitrary_pt - viewpoint
        n = self.normal_vector
        sign = np.dot(v, n)
        return sign > 0

    def is_coplanar(self, other):
        # type: (Polygon3D) -> bool
        """Check if polygon is in the same plane as another polygon.

        This includes the same plane but opposite orientation.

        :param other: Another polygon.
        :returns: True if the two polygons are coplanar, else False.

        """
        n1 = self.normal_vector
        n2 = other.normal_vector
        d1 = self.distance
        d2 = other.distance

        if almostequal(n1, n2) and almostequal(d1, d2):
            return True
        elif almostequal(n1, -n2) and almostequal(d1, -d2):
            return True
        else:
            return False

    def outside_point(self, entry_direction="counterclockwise"):
        # type: (str) -> Vector3D
        """Return a point outside the zone to which the surface belongs.

        The point will be outside the zone, respecting the global geometry rules
        for vertex entry direction.

        :param entry_direction: Either "clockwise" or "counterclockwise", as seen from outside the space.
        :returns: A point vector.

        """
        entry_direction = entry_direction.lower()
        if entry_direction == "clockwise":
            inside = self.vertices[0] - self.normal_vector
        elif entry_direction == "counterclockwise":
            inside = self.vertices[0] + self.normal_vector
        else:
            raise ValueError("invalid value for entry_direction '%s'" % entry_direction)
        return inside

    def order_points(self, starting_position):
        # type: (str) -> Polygon3D
        """Reorder the vertices based on a starting position rule.

        :param starting_position: The string that defines vertex starting position in EnergyPlus.
        :returns: The reordered polygon.

        """
        if starting_position == "upperleftcorner":
            bbox_corner = self.bounding_box[0]
        elif starting_position == "lowerleftcorner":
            bbox_corner = self.bounding_box[1]
        elif starting_position == "lowerrightcorner":
            bbox_corner = self.bounding_box[2]
        elif starting_position == "upperrightcorner":
            bbox_corner = self.bounding_box[3]
        else:
            raise ValueError("%s is not a valid starting position" % starting_position)
        start_index = self.index(bbox_corner.closest(self))
        new_vertices = [self[(start_index + i) % len(self)] for i in range(len(self))]

        return Polygon3D(new_vertices)

    def project_to_2D(self):
        # type: () -> Polygon2D
        """Project the 3D polygon into 2D space.

        This is so that we can perform operations on it using pyclipper library.

        Project onto either the xy, yz, or xz plane. (We choose the one that
        avoids degenerate configurations, which is the purpose of proj_axis.)

        :returns: A 2D polygon.

        """
        points = self.points_matrix
        projected_points = project_to_2D(points, self.projection_axis)

        return Polygon2D([pt[:2] for pt in projected_points])

    def normalize_coords(self, ggr):
        """Order points, respecting the global geometry rules

        :param ggr: EnergyPlus GlobalGeometryRules object.
        :returns: The normalized polygon.

        """
        try:
            entry_direction = ggr.Vertex_Entry_Direction
        except AttributeError:
            entry_direction = "counterclockwise"
        outside_point = self.outside_point(entry_direction)
        return normalize_coords(self, outside_point, ggr)

    def from_wkt(self, wkt_poly):
        # type: (str) -> Polygon3D
        """Convert a wkt representation of a polygon to GeomEppy.

        This also accounts for the possible presence of inner rings by linking them to the outer ring.

        :param wkt_poly: A text representation of a polygon in well known text (wkt) format.
        :returns: A polygon.

        """
        poly = wkt.loads(wkt_poly)
        exterior = Polygon3D(poly.exterior.coords)
        if poly.interiors:
            # make the exterior into a geomeppy poly
            for inner_ring in poly.interiors:
                # make the interior into a geomeppy poly
                interior = Polygon3D(inner_ring.coords)
                # find the nearest points on the exterior and interior
                links = list(product(interior, exterior))
                links = sorted(links, key=lambda x: x[0].relative_distance(x[1]))
                on_interior = links[0][0]
                on_exterior = links[0][1]
                # join them up
                exterior = Polygon3D(
                    exterior[exterior.index(on_exterior) :]
                    + exterior[: exterior.index(on_exterior) + 1]
                )
                interior = Polygon3D(
                    interior[interior.index(on_interior) :]
                    + interior[: interior.index(on_interior) + 1]
                )
                exterior = Polygon3D(exterior[:] + interior[:])

        return exterior class Vector2D(Sized, Iterable):
    """Two dimensional point."""

    def __init__(self, *args):
        # type: (*Any) -> None
        self.args = list(args)
        self.x = float(args[0])
        self.y = float(args[1])
        self.z = 0.0

    def __iter__(self):
        # type: () -> Iterator
        return (i for i in self.args)

    def __repr__(self):
        # type: () -> str
        class_name = type(self).__name__
        return "{}({!r}, {!r})".format(class_name, *self.args)

    def __eq__(self, other):
        for a, b in zip(self, other):
            if a != b:
                return False
        return True

    def __sub__(self, other):
        # type: (Any) -> Union[Vector2D, Vector3D]
        return self.__class__(*[self[i] - other[i] for i in range(len(self))])

    def __add__(self, other):
        # type: (Any) -> Union[Vector2D, Vector3D]
        return self.__class__(*[self[i] + other[i] for i in range(len(self))])

    def __neg__(self):
        # type: () -> Union[Vector2D, Vector3D]
        return self.__class__(*inverse_vector(self))

    def __len__(self):
        # type: () -> int
        return len(self.args)

    def __getitem__(self, key):
        # type: (Union[int, slice]) -> Union[Any, List[Any]]
        return self.args[key]

    def __setitem__(self, key, value):
        self.args[key] = value

    def __hash__(self):
        return hash(self.x) ^ hash(self.y)

    def dot(self, other):
        # type: (Vector3D) -> np.float64
        return np.dot(self, other)

    def cross(self, other):
        # type: (Union[Vector2D, Vector3D]) -> np.ndarray
        return np.cross(self, other)

    @property
    def length(self):
        # type: () -> float
        """The length of a vector."""
        length = sum(x ** 2 for x in self.args) ** 0.5

        return length

    def closest(self, poly):
        # type: (Polygon3D) -> Optional[Any]
        """Find the closest vector in a polygon.

        :param poly: Polygon or Polygon3D

        """
        min_d = float("inf")
        closest_pt = None
        for pt2 in poly:
            direction = self - pt2
            sq_d = sum(x ** 2 for x in direction)
            if sq_d < min_d:
                min_d = sq_d
                closest_pt = pt2
        return closest_pt

    def normalize(self):
        # type: () -> Union[Vector2D, Vector3D]
        return self.set_length(1.0)

    def set_length(self, new_length):
        # type: (float) -> Union[Vector2D, Vector3D]
        current_length = self.length
        multiplier = new_length / current_length
        self.args = [i * multiplier for i in self.args]
        return self

    def invert(self):
        # type: () -> Union[Vector2D, Vector3D]
        return -self

    def as_array(self, dims=3):
        # type: (Union[Vector2D, Vector3D], int) -> np.ndarray
        """Convert a point to a numpy array.

        Converts a Vector3D to a numpy.array([x,y,z]) or a Vector2D to a numpy.array([x,y]).
        Ensures all values are floats since some other types cause problems in pyclipper (notably where sympy.Zero is
        used to represent 0.0).

        :param pt: The point to convert.
        :param dims: Number of dimensions {default : 3}.
        :returns: Vector as a Numpy array.

        """
        # handle Vector3D
        if dims == 3:
            return np.array([float(self.x), float(self.y), float(self.z)])
        # handle Vector2D
        elif dims == 2:
            return np.array([float(self.x), float(self.y)])
        else:
            raise ValueError("%s-dimensional vectors are not supported." % dims)

    def as_tuple(self, dims=3):
        # type: (Union[Vector2D, Vector3D, int]) -> Union[Tuple[float, float], Tuple[float, float, float]]
        """Convert a point to a numpy array.

        Convert a Vector3D to an (x,y,z) tuple or a Vector2D to an (x,y) tuple.
        Ensures all values are floats since some other types cause problems in pyclipper (notably where sympy.Zero is
        used to represent 0.0).

        :param pt: The point to convert.
        :param dims: Number of dimensions {default : 3}.
        :returns: Vector as a tuple.

        """
        # handle Vector3D
        if dims == 3:
            return float(self.x), float(self.y), float(self.z)
        # handle Vector2D
        elif dims == 2:
            return float(self.x), float(self.y)
        else:
            raise ValueError("%s-dimensional vectors are not supported." % dims)

    def relative_distance(self, v2):
        # type: (Vector3D) -> float
        """A distance function for sorting vectors by distance.

        This only provides relative distance, not actual distance since we only use it for sorting.

        :param v2: Another vector.
        :returns: Relative distance between two point vectors.

        """
        direction = self - v2
        return sum(x ** 2 for x in direction) class Vector3D(Vector2D):
    """Three dimensional point."""

    def __init__(
        self,
        x,  # type: Union[float, np.float64]
        y,  # type: Union[float, np.float64]
        z=0,  # type: Union[float, np.float64]
    ):
        # type: (...) -> None
        super(Vector3D, self).__init__(x, y, z)
        self.z = float(z)
        self.args = [self.x, self.y, self.z]

    def __repr__(self):
        # type: () -> str
        class_name = type(self).__name__
        return "{}({!r}, {!r}, {!r})".format(class_name, *self.args)

    def __hash__(self):
        # type: () -> int
        return hash(self.x) ^ hash(self.y) ^ hash(self.z) def rotate(surfaces, angle):
    # type: (Union[List[EpBunch], Idf_MSequence], float) -> None
    """Rotate all surfaces by an angle.

    :param surfaces: A list of EpBunch objects or a mutable sequence.
    :param angle: An angle in degrees.

    """
    radians = np.deg2rad(angle)
    for s in surfaces:
        if not s.coords:
            warnings.warn(
                "%s was not affected by this operation since it does not define vertices."
                % s.Name
            )
            continue
        new_coords = rotate_coords(s.coords, radians)
        s.setcoords(new_coords) def set_wwr(
    idf, wwr=0.2, construction=None, force=False, wwr_map=None, orientation=None
):
    # type: (IDF, Optional[float], Optional[str], Optional[bool], Optional[dict], Optional[str]) -> None
    """Set the window to wall ratio on all external walls.

    :param idf: The IDF to edit.
    :param wwr: The window to wall ratio.
    :param construction: Name of a window construction.
    :param force: True to remove all subsurfaces before setting the WWR.
    :param wwr_map: Mapping from wall orientation (azimuth) to WWR, e.g. {180: 0.25, 90: 0.2}.
    :param orientation: One of "north", "east", "south", "west". Walls within 45 degrees will be affected.

    """
    try:
        ggr = idf.idfobjects["GLOBALGEOMETRYRULES"][0]  # type: Optional[Idf_MSequence]
    except IndexError:
        ggr = None

    # check orientation
    orientations = {
        "north": 0.0,
        "east": 90.0,
        "south": 180.0,
        "west": 270.0,
        None: None,
    }
    degrees = orientations.get(orientation, None)
    external_walls = filter(
        lambda x: x.Outside_Boundary_Condition.lower() == "outdoors",
        idf.getsurfaces("wall"),
    )
    external_walls = filter(
        lambda x: _has_correct_orientation(x, degrees), external_walls
    )
    subsurfaces = idf.getsubsurfaces()
    base_wwr = wwr
    for wall in external_walls:
        # get any subsurfaces on the wall
        wall_subsurfaces = list(
            filter(lambda x: x.Building_Surface_Name == wall.Name, subsurfaces)
        )
        if not all(_is_window(wss) for wss in wall_subsurfaces) and not force:
            raise ValueError(
                'Not all subsurfaces on wall "{name}" are windows. '
                "Use `force=True` to replace all subsurfaces.".format(name=wall.Name)
            )

        if wall_subsurfaces and not construction:
            constructions = list(
                {wss.Construction_Name for wss in wall_subsurfaces if _is_window(wss)}
            )
            if len(constructions) > 1:
                raise ValueError(
                    'Not all subsurfaces on wall "{name}" have the same construction'.format(
                        name=wall.Name
                    )
                )
            construction = constructions[0]
        # remove all subsurfaces
        for ss in wall_subsurfaces:
            idf.removeidfobject(ss)
        wwr = (wwr_map or {}).get(wall.azimuth) or base_wwr
        if not wwr:
            return
        coords = window_vertices_given_wall(wall, wwr)
        window = idf.newidfobject(
            "FENESTRATIONSURFACE:DETAILED",
            Name="%s window" % wall.Name,
            Surface_Type="Window",
            Construction_Name=construction or "",
            Building_Surface_Name=wall.Name,
            View_Factor_to_Ground="autocalculate",  # from the surface angle
        )
        window.setcoords(coords, ggr) def translate(
    surfaces, vector
):  # type: (Idf_MSequence, Union[Tuple[float, float], Vector2D, Vector3D]) -> None
    """Translate all surfaces by a vector.

    :param surfaces: A list of EpBunch objects.
    :param vector: Representation of a vector to translate by.

    """
    vector = Vector3D(*vector)
    for s in surfaces:
        if not s.coords:
            warnings.warn(
                "%s was not affected by this operation since it does not define vertices."
                % s.Name
            )
            continue
        new_coords = translate_coords(s.coords, vector)
        s.setcoords(new_coords) def translate_to_origin(idf):
    # type: (IDF) -> None
    """Move an IDF close to the origin so that it can be viewed in SketchUp.

    :param idf: The IDF to edit.

    """
    surfaces = idf.getsurfaces()
    subsurfaces = idf.getsubsurfaces()
    shading_surfaces = idf.getshadingsurfaces()

    min_x = min(min(Polygon3D(s.coords).xs) for s in surfaces)
    min_y = min(min(Polygon3D(s.coords).ys) for s in surfaces)

    translate(surfaces, (-min_x, -min_y))
    translate(subsurfaces, (-min_x, -min_y))
    translate(shading_surfaces, (-min_x, -min_y)) def almostequal(first, second, places=7):
    # type: (Any, Any, int) -> bool
    """Tests a range of types for near equality."""
    try:
        # try converting to float first
        first = float(first)
        second = float(second)
        # test floats for near-equality
        if round(abs(second - first), places) != 0:
            return False
        else:
            return True
    except ValueError:
        # handle non-float types
        return str(first) == str(second)
    except TypeError:
        # handle iterables
        return all([almostequal(a, b, places) for a, b in zip(first, second)]) def _get_collections(idf, opacity=1):
    """Set up 3D collections for each surface type."""
    surfaces = _get_surfaces(idf)
    # set up the collections
    walls = _get_collection("wall", surfaces, opacity, facecolor="lightyellow")
    floors = _get_collection("floor", surfaces, opacity, facecolor="dimgray")
    roofs = _get_collection("roof", surfaces, opacity, facecolor="firebrick")
    windows = _get_collection("window", surfaces, opacity, facecolor="cornflowerblue")
    shading = _get_collection("shading", surfaces, opacity, facecolor="darkolivegreen")

    return walls, roofs, floors, windows, shading def _get_shading(idf):
    """Get the shading surfaces from the IDF."""
    shading_types = ["SHADING:ZONE:DETAILED", "SHADING:SITE:DETAILED"]
    shading = []
    for shading_type in shading_types:
        shading.extend(idf.idfobjects[shading_type])

    return shading def _get_surfaces(idf):
    """Get the surfaces from the IDF.
    """
    surfaces = idf.getsurfaces() + idf.getshadingsurfaces() + idf.getsubsurfaces()
    return surfaces
from geomeppy.idf import IDF
from geomeppy.geom.intersect_match import intersect_idf_surfaces, match_idf_surfaces
from geomeppy.geom.polygons import Polygon3D
from geomeppy.geom.vectors import Vector2D, Vector3D
from geomeppy.recipes import rotate, set_wwr, translate, translate_to_origin
from geomeppy.utilities import almostequal
from geomeppy.view_geometry import _get_collections, _get_shading, _get_surfaces
"""Tests for recipes."""


class TestTranslate:
    def test_translate(self, base_idf):
        # type: (IDF) -> None
        idf = base_idf
        surfaces = idf.getsurfaces()
        translate(surfaces, (50, 100))  # move to x + 50, y + 100
        result = Polygon3D(surfaces[0].coords).xs
        expected = [52.0, 52.0, 51.0, 51.0]
        assert result == expected
        translate(surfaces, (-50, -100))  # move back

        translate(surfaces, Vector2D(50, 100))  # move to x + 50, y + 100
        result = Polygon3D(surfaces[0].coords).xs
        expected = [52.0, 52.0, 51.0, 51.0]
        assert result == expected
        translate(surfaces, (-50, -100))  # move back

        translate(surfaces, Vector3D(50, 100, 0))  # move to x + 50, y + 100
        result = Polygon3D(surfaces[0].coords).xs
        expected = [52.0, 52.0, 51.0, 51.0]
        assert result == expected

    def test_translate_to_origin(self, base_idf):
        # type: (IDF) -> None
        idf = base_idf
        surfaces = idf.getsurfaces()
        translate(surfaces, (50000, 10000))  # move to x + 50, y + 100
        result = Polygon3D(surfaces[0].coords).xs
        expected = [50002.0, 50002.0, 50001.0, 50001.0]
        assert result == expected

        translate_to_origin(idf)
        surfaces = idf.getsurfaces()
        min_x = min(min(Polygon3D(s.coords).xs) for s in surfaces)
        min_y = min(min(Polygon3D(s.coords).ys) for s in surfaces)
        assert min_x == 0
        assert min_y == 0

    def test_rotate_360(self, base_idf):
        # type: (IDF) -> None
        idf = base_idf
        surface = idf.getsurfaces()[0]
        coords = [Vector3D(*v) for v in [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)]]
        surface.setcoords(coords)
        expected = surface.coords
        rotate([surface], 360)
        result = surface.coords
        assert almostequal(result, expected)

    def test_rotate_idf_360(self, base_idf):
        # type: (IDF) -> None
        idf1 = base_idf
        idf2 = IDF()
        idf2.initreadtxt(idf1.idfstr())
        idf1.rotate(360)
        floor1 = Polygon3D(idf1.getsurfaces("floor")[0].coords).normalize_coords(None)
        floor2 = Polygon3D(idf2.getsurfaces("floor")[0].coords).normalize_coords(None)
        assert almostequal(floor1, floor2)
        shade1 = Polygon3D(idf1.getshadingsurfaces()[0].coords).normalize_coords(None)
        shade2 = Polygon3D(idf1.getshadingsurfaces()[0].coords).normalize_coords(None)
        assert almostequal(shade1, shade2)

    def test_centre(self, base_idf):
        # type: (IDF) -> None
        idf = base_idf
        result = idf.centroid
        expected = Vector2D(1.75, 2.025)
        assert result == expected

    def test_scale_idf(self, base_idf):
        # type: (IDF) -> None
        idf1 = base_idf
        idf2 = IDF()
        idf2.initreadtxt(idf1.idfstr())
        idf1.scale(10)
        idf1.scale(0.1)
        floor1 = Polygon3D(idf1.getsurfaces("floor")[0].coords).normalize_coords(None)
        floor2 = Polygon3D(idf2.getsurfaces("floor")[0].coords).normalize_coords(None)
        assert almostequal(floor1, floor2)


class TestMatchSurfaces:
    def test_set_wwr(self, base_idf):
        # type: (IDF) -> None
        idf = base_idf

        intersect_idf_surfaces(idf)