Source code for honeybee_ies.writer

import re
import json
import pathlib
import math
from typing import List, Union, Dict

from ladybug_geometry.geometry3d import Face3D, Polyface3D, Point3D
from honeybee.model import Model, Shade, Room, AirBoundary, \
    RoofCeiling, Floor, ShadeMesh

from .reader import Z_AXIS, ROOF_ANGLE_TOLERANCE
from .types import GEM_TYPES


def _gen_ve_id(display_name: str) -> str:
    """Generate the VE identifier based on the name of the room.

    VE takes the first two alphanumerical values except for vowels from the display name.
    Then it adds an integer. For instance Room will be translated to RM000000. 1st floor
    will be translated to 1S000000. If there is already another room with the same 2
    characters then it adds to the number. RM000001, RM000002, and so on.

    This method only returns the 2 characters.
    """
    # Match any vowel character (case-insensitive), any whitespace, or any \
    # non-alphanumeric character
    pattern = r"[aeiouAEIOU\s\W]+"
    # Replace matched characters with an empty string
    identifier = re.sub(pattern, '', display_name).upper()
    if len(identifier) < 2:
        raise ValueError(
            f'Invalid display name: {display_name}. The display name should at '
            'least have two characters after removing the vowels.'
        )
    return identifier[:2]


def _convert_room_ids(model: Model) -> Dict:
    """Convert room identifiers to a valid VE identifier.

    This method updates the model directly, and returns a dictionary that maps the
    original identifier to the new VE identifier. The mapper is useful to find the
    rooms by ID from inside IES VE python editor.
    """
    id_mapper = {}
    id_counter = {}
    for room in model.rooms:
        room: Room
        identifier = room.identifier
        ve_identifier = _gen_ve_id(room.display_name)
        if ve_identifier not in id_counter:
            id_counter[ve_identifier] = -1
        id_counter[ve_identifier] += 1
        full_ve_id = f'{ve_identifier}{id_counter[ve_identifier]:06d}'
        id_mapper[identifier] = full_ve_id
        try:
            room.identifier = full_ve_id
        except AssertionError:
            room.identifier = f'RM{id_counter[ve_identifier]:06d}'
    return id_mapper


def _opening_to_ies(
        parent_geo: Face3D, opening_geos: List[Face3D], opening_type: int = 0) -> str:
    """Translate an opening to gem format.

    Args:
        parent_geo: Geometry of the parent object.
        opening_geos: A list of Face3D geometries for openings.
        opening_type: An integer between 0-2. 0 for apertures, 1 for doors and 2 for
            holes.

    Returns:
        A formatted string for the opening.

    """
    if parent_geo.plane.n.z in (1, -1):
        origin, flip = parent_geo.upper_right_corner, True
    elif parent_geo.plane.n.angle(Z_AXIS) < ROOF_ANGLE_TOLERANCE:
        origin, flip = parent_geo.lower_left_corner, True
    else:
        origin, flip = parent_geo.lower_left_corner, False
    openings = []
    for opening in opening_geos:
        verts_2d = parent_geo.polygon_in_face(opening, origin, flip)
        openings.append('{} {}'.format(len(verts_2d), opening_type))
        openings.append(
            '\n'.join('   {:.6f}    {:.6f}'.format(v.x, v.y) for v in verts_2d)
        )
    return '\n'.join(openings)


def _vertices_to_ies(vertices: List[Point3D]) -> str:
    """Get a string for vertices in GEM format."""
    vertices = '\n'.join(
        '   {:.6f}    {:.6f}    {:.6f}'.format(v.x, v.y, v.z)
        for v in vertices
    )
    return vertices


def _shade_geometry_to_ies(
    geometry: Union[Face3D, Polyface3D, ShadeMesh], name: str,
    is_detached: bool = True, identifier: str = None,
    user_data: Dict = None
        ):

    open_count = 0
    faces = []

    gem_type = GEM_TYPES.from_user_data(user_data)
    if not gem_type:
        # it is a shade or a context building
        gem_type = GEM_TYPES.ContextBuilding if is_detached \
            else GEM_TYPES.Shade

    if gem_type == GEM_TYPES.PV:
        # calculate the bounds of the geometry based and translate it back to
        # GEM format
        w, h = geometry.boundary_polygon2d.max - geometry.boundary_polygon2d.min
        base = geometry.lower_right_corner
        yz_rotation = abs(90 - round(math.degrees(geometry.plane.altitude), 6))
        xy_rotation = abs(360 - round(math.degrees(geometry.plane.azimuth), 6))
        pv_info = f'{round(base.x, 4)} {round(base.y, 4)} {round(base.z, 4)} ' \
            f'{round(w, 4)} {round(h, 4)} ' \
            f'{round(xy_rotation, 4)} {round(yz_rotation, 4)}'
        return gem_type.to_gem(
                name=name, identifier=identifier, vertices=pv_info
            )

    if gem_type == GEM_TYPES.Tree:
        # TODO: create objects for every IES object to remove duplicate values
        # like the scales here
        x_scale = 3
        y_scale = 3
        z_scale = 8
        tree_type = user_data.get('__gem_tree_type__', 1)
        w, h = geometry.boundary_polygon2d.max - geometry.boundary_polygon2d.min
        base = (geometry.lower_right_corner + geometry.lower_left_corner) / 2
        # this logic won't work for values larger than 180 but that's for later
        xy_rotation = abs(90 - round(math.degrees(geometry.plane.azimuth), 6))
        yz_rotation = round(math.degrees(geometry.plane.altitude), 6)
        tree_info = f'2D Tree {tree_type}\n' \
            f'{round(base.x, 4)} {round(base.y, 4)} {round(base.z, 4)} ' \
            f'{round(w / x_scale, 4)} {round(w / y_scale, 4)} {round(h / z_scale, 4)} ' \
            f'{round(xy_rotation, 4)} {round(yz_rotation, 4)}'
        return gem_type.to_gem(
                name=name, identifier=identifier, vertices=tree_info
            )

    if isinstance(geometry, Polyface3D):
        unique_vertices = geometry.vertices
        vertices = _vertices_to_ies(unique_vertices)
        for face_i, face in zip(geometry.face_indices, geometry.faces):
            index = [str(v + 1) for v in face_i[0]]
            face_str = '%d %s\n' % (len(index), ' '.join(index))
            open_count, openings = 0, []
            if face.has_holes:
                sub_faces = [Face3D(hole, face.plane) for hole in face.holes]
                openings.append(_opening_to_ies(face, sub_faces, 2))
                open_count += len(sub_faces)

            open_str = '\n' + '\n'.join(openings) if len(openings) != 0 else ''
            faces.append('%s%d%s' % (face_str, open_count, open_str))
        face_count = len(geometry.faces)
    elif isinstance(geometry, ShadeMesh):
        # ShadeMesh
        unique_vertices = geometry.vertices
        vertices = _vertices_to_ies(unique_vertices)
        for face in geometry.faces:
            index = [str(v + 1) for v in face]
            face_str = '%d %s\n' % (len(index), ' '.join(index))
            faces.append(f'{face_str}0')
        face_count = len(geometry.faces)
    else:
        # Face 3D
        unique_vertices = geometry.lower_left_counter_clockwise_vertices
        vertices = _vertices_to_ies(unique_vertices)
        index = [str(v + 1) for v in range(len(unique_vertices))]
        face_str = '%d %s\n' % (len(index), ' '.join(index))
        open_count, openings = 0, []
        if geometry.has_holes:
            sub_faces = [Face3D(hole, geometry.plane) for hole in geometry.holes]
            openings.append(_opening_to_ies(geometry, sub_faces, 2))
            open_count += len(sub_faces)
        open_str = '\n' + '\n'.join(openings) if len(openings) != 0 else ''
        faces.append('%s%d%s' % (face_str, open_count, open_str))
        face_count = 1

    if len(unique_vertices) < 3:
        # a check for line-like mesh faces
        print('Invalid line-like shade object found and removed.')
        return ''

    return gem_type.to_gem(
        name=name, identifier=identifier,
        vertices_count=len(unique_vertices),
        vertices=vertices,
        faces='\n'.join(faces),
        face_count=face_count
    )


def _shade_group_to_ies(shades: List[Shade]) -> str:
    """Convert a group of shades into a GEM string.

    The files in the shade group should create a closed volume. The translator uses
    the name of the first shade as the name of the group.
    """
    group_geometry = Polyface3D.from_faces(
        [shade.geometry for shade in shades], tolerance=0.001
    )
    first_shade = shades[0]
    # remove new lines from the name
    shade_name = ' '.join(first_shade.display_name.split())
    return _shade_geometry_to_ies(
        group_geometry, shade_name, first_shade.is_detached,
        first_shade.identifier, first_shade.user_data
    )


def _shade_to_ies(shade: Shade, thickness: float = 0.01) -> str:
    """Convert a single Honeybee Shade to a GEM string.

    Args:
        shade: A Shade face.
        thickness:The thickness of the shade face in meters. IES doesn't consider the
            effect of shades with no thickness in SunCalc. This function extrudes the
            geometry to create a closed volume for the shade. Default: 0.01

    Returns:
        A formatted string that represents this shade in GEM format.

    """
    if thickness == 0:
        # don't add the thickness
        shade_geo = shade.geometry
    else:
        geometry = shade.geometry
        move_vector = geometry.normal.reverse().normalize() * thickness / 2
        base_geo = geometry.move(move_vector)
        shade_geo = Polyface3D.from_offset_face(base_geo, thickness)

    # remove new lines from the name
    shade_name = ' '.join(shade.display_name.split())
    return _shade_geometry_to_ies(
        shade_geo, shade_name, is_detached=shade.is_detached,
        identifier=shade.identifier, user_data=shade.user_data
    )


[docs] def shades_to_ies(shades: List[Shade], thickness: float = 0.01) -> str: """Convert a list of Shades to a GEM string. Args: shades: A list of Shade faces. thickness:The thickness of the shade face in meters. This value will be used to extrude shades with no group id. IES doesn't consider the effect of shades with no thickness in SunCalc. This function extrudes the geometry to create a closed volume for the shade. Default: 0.01 Returns: A formatted string that represents this shade in GEM format. """ shade_groups = {} no_groups = [] for shade in shades: user_data = shade.user_data if user_data and '__ies_import__' in user_data \ and user_data['__ies_import__']: # ignore the shade face with __ies_import__ key continue try: group_id = shade.user_data['__group_id__'] except (TypeError, KeyError): no_groups.append(shade) continue else: if group_id not in shade_groups: shade_groups[group_id] = [shade] else: shade_groups[group_id].append(shade) single_keys = [key for key, value in shade_groups.items() if len(value) == 1] for key in single_keys: v = shade_groups.pop(key) no_groups.append(v[0]) single_shades = '\n'.join([_shade_to_ies(shade, thickness) for shade in no_groups]) group_shades = '\n'.join( [_shade_group_to_ies(shades) for shades in shade_groups.values()] ) return '\n'.join((single_shades, group_shades))
[docs] def shade_meshes_to_ies(shades: List[ShadeMesh]) -> str: """Convert a list of ShadeMeshes to a GEM string. Args: shades: A list of ShadeMeshes. Returns: A formatted string that represents this shade in GEM format. """ return '' if not shades else \ '\n'.join( _shade_geometry_to_ies( geometry=shade, name=shade.display_name, is_detached=shade.is_detached, identifier=shade.identifier, user_data=shade.user_data ) for shade in shades )
[docs] def room_to_ies(room: Room, shade_thickness: float = 0.01) -> str: """Convert a Honeybee Shade to a GEM string. Args: room: A Honeybee Room. shade_thickness:The thickness of the shade face in meters. This value will be used to extrude shades with no group id. IES doesn't consider the effect of shades with no thickness in SunCalc. This function extrudes the geometry to create a closed volume for the shade. Default: 0.01 Returns: A formatted string that represents this room in GEM format. """ def _find_index(vertex: Point3D, vertices: List[Point3D], tolerance=0.01): for c, v in enumerate(vertices): if v.distance_to_point(vertex) <= tolerance: return str(c + 1) else: raise ValueError(f'Failed to find {vertex} in the vertices.') # remove new lines from the name room.display_name = ' '.join(room.display_name.split()) unique_vertices = room.geometry.vertices vertices = _vertices_to_ies(unique_vertices) face_count = len(room.faces) faces = [] air_boundary_count = 0 _key = '__ies_import__' for face_i, face in zip(room.geometry.face_indices, room.faces): if isinstance(face.type, AirBoundary) and face.user_data \ and _key in face.user_data: # This air boundary was created during the process of importing holes # from an IES GEM file. We don't write these air boundaries back to GEM. air_boundary_count += 1 continue if isinstance(face.type, (RoofCeiling, Floor)) and face.geometry.has_holes: # IES doesn't like rooms with holes in them. We need to break the face # into smaller faces try: fgs = face.geometry.split_through_holes() except AssertionError as e: if 'There must be at least 3 vertices for a Polygon2D' not in str(e): raise AssertionError(e) # ignore the hole print( f'Failed to resolve the holes for {room.display_name}. Check the ' 'input model to ensure the holes are not outside the parent face.' ) fgs = [face.geometry] indexes = [[str(v + 1) for v in face_i[0]]] else: face_count += len(fgs) - 1 indexes = [ [ _find_index(v, unique_vertices) for v in fg.lower_left_counter_clockwise_vertices ] for fg in fgs ] else: fgs = [face.geometry] indexes = [[str(v + 1) for v in face_i[0]]] for index, fg in zip(indexes, fgs): face_str = '%d %s\n' % (len(index), ' '.join(index)) open_count, openings = 0, [] if isinstance(face.type, AirBoundary): # add the face itself as the hole sub_faces = [Face3D(fg.vertices, fg.plane)] openings.append(_opening_to_ies(fg, sub_faces, 2)) open_count += 1 elif fg.has_holes: sub_faces = [Face3D(hole, fg.plane) for hole in fg.holes] openings.append(_opening_to_ies(fg, sub_faces, 2)) open_count += len(sub_faces) if len(face.apertures) != 0: sub_faces = [ap.geometry for ap in face.apertures] openings.append(_opening_to_ies(fg, sub_faces, 0)) open_count += len(sub_faces) if len(face.doors) != 0: sub_faces = [dr.geometry for dr in face.doors] openings.append(_opening_to_ies(fg, sub_faces, 1)) open_count += len(sub_faces) open_str = '\n' + '\n'.join(openings) if len(openings) != 0 else '' faces.append('%s%d%s' % (face_str, open_count, open_str)) space = GEM_TYPES.Space.to_gem( name=room.display_name, identifier=room.identifier, vertices_count=len(unique_vertices), face_count=face_count - air_boundary_count, vertices=vertices, faces='\n'.join(faces) ) # collect all the shades from room shades = [shade for shade in room.shades] for face in room.faces: for aperture in face.apertures: for shade in aperture.shades: shades.append(shade) for door in face.doors: for shade in door.shades: shades.append(shade) if shades: formatted_shades = shades_to_ies(shades=shades, thickness=shade_thickness) return '\n'.join((space, formatted_shades)) else: return space
[docs] def model_to_gem(model: Model, shade_thickness: float = 0.0): """Generate an IES GEM string representation of a Model. Args: model: A honeybee model. shade_thickness:The thickness of the shade face in meters. This value will be used to extrude shades with no group id. IES doesn't consider the effect of shades with no thickness in SunCalc. This function extrudes the geometry to create a closed volume for the shade. (Default: 0.0). Returns: Text string representation of the contents of a GEM file derived from the input model. """ # ensure model is in metric and has identifiers that are acceptable for GEM model = model.duplicate() model.convert_to_units(units='Meters') _convert_room_ids(model) # create and return the GEM file string header = 'COM GEM data file exported by Pollination\\nANT' rooms_data = [room_to_ies(room, shade_thickness=shade_thickness) for room in model.rooms] context_shades = shades_to_ies(model.shades, thickness=shade_thickness) mesh_shades = shade_meshes_to_ies(model.shade_meshes) gem_data = [header] + rooms_data + [context_shades, mesh_shades] return '\n'.join(gem_data)
[docs] def model_to_ies( model: Model, folder: str = '.', name: str = None, shade_thickness: float = 0.0, write_id_mapper=True ) -> pathlib.Path: """Export a honeybee model to an IES GEM file. Args: model: A honeybee model. folder: Path to target folder to export the file. Default is current folder. name: An optional name for exported file. By default the name of the model will be used. shade_thickness:The thickness of the shade face in meters. This value will be used to extrude shades with no group id. IES doesn't consider the effect of shades with no thickness in SunCalc. This function extrudes the geometry to create a closed volume for the shade. Default: 0.0 write_id_mapper: A boolean to indicate if the id mapper file should be written next to the gem file. It is a JSON file that maps the original identifier of the rooms in the honeybee model to the new identifier in GEM file. Returns: Path to exported GEM file. """ # ensure model is in metric and has identifiers that are acceptable for GEM model = model.duplicate() model.convert_to_units(units='Meters') id_mapper = _convert_room_ids(model) # get the text for the GEM file contents header = 'COM GEM data file exported by Pollination\nANT\n' rooms_data = [room_to_ies(room, shade_thickness=shade_thickness) for room in model.rooms] context_shades = shades_to_ies(model.shades, thickness=shade_thickness) mesh_shades = shade_meshes_to_ies(model.shade_meshes) # write to GEM name = name or model.display_name if not name.lower().endswith('.gem'): name = f'{name}.gem' out_folder = pathlib.Path(folder) out_folder.mkdir(parents=True, exist_ok=True) out_file = out_folder.joinpath(name) with out_file.open('w', encoding='utf-8') as outf: outf.write(header) outf.write('\n'.join(rooms_data) + '\n') outf.write(context_shades) outf.write(mesh_shades) if write_id_mapper: mapper_name = f'{name[:-4]}.im.json' mapper_out_file = out_folder.joinpath(mapper_name) mapper_out_file.write_text(json.dumps(id_mapper)) return out_file