diff --git a/honeybee_idaice/cli/translate.py b/honeybee_idaice/cli/translate.py index b3f79db..9d0771c 100644 --- a/honeybee_idaice/cli/translate.py +++ b/honeybee_idaice/cli/translate.py @@ -47,14 +47,6 @@ def translate(): 'interior geometries are not perfectly matching one another.', type=str, default='0.4m', show_default=True ) -@click.option( - '--frame-thickness', '-f', help='Maximum thickness of the window frame. This ' - 'can include the units of the distance (eg. 4in) or, if no units are provided, ' - 'the value will be assumed to be in meters (the native units of IDA-ICE). ' - 'This will be used to join any non-rectangular Apertures together in' - 'an attempt to better rectangularize them for IDM.', - type=str, default='0.1m', show_default=True -) @click.option( '--name', '-n', help='Deprecated option to set the name of the output file.', default=None, show_default=True @@ -68,8 +60,7 @@ def translate(): 'of the translation. By default this will be printed out to stdout.', type=click.File('w'), default='-', show_default=True) def model_to_idm( - model_json, wall_thickness, adjacency_distance, frame_thickness, - name, folder, output_file + model_json, wall_thickness, adjacency_distance, name, folder, output_file ): """Translate a Model JSON file to an IDA-ICE IDM file. \b @@ -81,7 +72,6 @@ def model_to_idm( # convert distance strings to floats wall_thickness = parse_distance_string(str(wall_thickness), 'Meters') adjacency_distance = parse_distance_string(str(adjacency_distance), 'Meters') - frame_thickness = parse_distance_string(str(frame_thickness), 'Meters') # translate the Model to IDM model = Model.from_file(model_json) @@ -90,8 +80,7 @@ def model_to_idm( folder.mkdir(parents=True, exist_ok=True) model.to_idm(folder.as_posix(), name=name, debug=False, max_int_wall_thickness=wall_thickness, - max_adjacent_sub_face_dist=adjacency_distance, - max_frame_thickness=frame_thickness) + max_adjacent_sub_face_dist=adjacency_distance) else: if output_file.name == '': # get a temporary file out_file = str(uuid.uuid4())[:6] @@ -100,8 +89,7 @@ def model_to_idm( out_folder, out_file = os.path.split(output_file.name) idm_file = model.to_idm(out_folder, name=out_file, debug=False, max_int_wall_thickness=wall_thickness, - max_adjacent_sub_face_dist=adjacency_distance, - max_frame_thickness=frame_thickness) + max_adjacent_sub_face_dist=adjacency_distance) if output_file.name == '': # load file contents to stdout with open(idm_file, 'rb') as of: # IDM can only be read as binary f_contents = of.read() diff --git a/honeybee_idaice/face.py b/honeybee_idaice/face.py index 99ffd63..81257b4 100644 --- a/honeybee_idaice/face.py +++ b/honeybee_idaice/face.py @@ -1,11 +1,31 @@ import math from typing import Union -from ladybug_geometry.geometry2d import Point2D -from ladybug_geometry.geometry3d import Point3D, Vector3D, Plane +from ladybug_geometry.geometry2d import Point2D, Polygon2D, Vector2D +from ladybug_geometry.geometry3d import Point3D, Vector3D, Plane, Face3D from honeybee.model import Face, Aperture, Door +def _is_straight_rectangle(opening_poly: Polygon2D, ang_tol): + """Check if the window is rectangular and in parallel to XY axis.""" + if len(opening_poly.vertices) != 4: + return False + if not opening_poly.is_rectangle(ang_tol): + return False + x_axis_2d = Vector2D(1, 0) + first_edge = opening_poly[1] - opening_poly[0] + first_edge_ang = x_axis_2d.angle(first_edge) + # technically we should not need to check for both angles but + # from the tests that I (mostapha) have seen the sorted coordinates + # are not always sorted from lower-left and counter clockwise. + if not ( + first_edge_ang <= ang_tol + or abs(first_edge_ang - math.pi) <= ang_tol + ): + return False + return True + + def opening_to_idm( opening: Union[Aperture, Door], ref_plane: Plane, is_aperture=True, decimal_places: int = 3, angle_tolerance: float = 1.0) -> str: @@ -24,42 +44,85 @@ def opening_to_idm( from the World Z before the opening is treated as being in the World XY plane. (Default: 1). """ - # get the name - name = opening.identifier + ang_tol = math.radians(angle_tolerance) + # IDA-ICE looks to apertures from inside the room + opening_geo = opening.geometry.flip() + corners_idm = '' + ver_count = len(opening_geo.vertices) + # rectangle based on opening reference plane + apt_llc = opening_geo.lower_left_corner + apt_urc = opening_geo.upper_right_corner + + def opening_corners_to_idm( + opening_geo: Face3D, ref_plane: Plane, min_2d: Point2D, + ang_tol: float, is_horizontal): + # calculate 2D polygon + opening_poly = Polygon2D( + [ + ref_plane.xyz_to_xy(v) + for v in opening_geo.lower_left_counter_clockwise_vertices + ] + ) + + if _is_straight_rectangle(opening_poly, ang_tol): + # no need to use corners method + return '' + + min_2d_apt = ref_plane.xyz_to_xy(opening_geo.flip().lower_left_corner) + if is_horizontal and min_2d.y + min_2d_apt.y < 0.001: + corners = ' '.join( + f'({round(v.x - min_2d.x, decimal_places)} ' + f'{round(-v.y - min_2d.y, decimal_places)})' + for v in opening_poly.vertices + ) + else: + corners = ' '.join( + f'({round(v.x - min_2d.x, decimal_places)} ' + f'{round(v.y - min_2d.y, decimal_places)})' + for v in opening_poly.vertices + ) + + corners_idm = '\n ((AGGREGATE :N SHAPE :T SHAPE2D)\n' \ + f' (:PAR :N NCORN :V {ver_count} :S (:DEFAULT NIL 2))\n' \ + f' (:PAR :N CORNERS :DIM ({ver_count} 2) :V #2A({corners}))\n' \ + f' (:PAR :N CONTOURS :V NIL))' + + return corners_idm # if the aperture is horizontal, use the world XY - ang_tol = math.radians(angle_tolerance) vertical = Vector3D(0, 0, 1) vert_ang = ref_plane.n.angle(vertical) - if vert_ang <= ang_tol or vert_ang >= math.pi - ang_tol: + is_horizontal = vert_ang <= ang_tol or vert_ang >= math.pi - ang_tol + + if is_horizontal: + # horizontal aperture min_2d = Point2D(opening.min.x - ref_plane.o.x, opening.min.y - ref_plane.o.y) max_2d = Point2D(opening.max.x - ref_plane.o.x, opening.max.y - ref_plane.o.y) - height = round(max_2d.y - min_2d.y, decimal_places) - width = round(max_2d.x - min_2d.x, decimal_places) else: - # IDA-ICE looks to apertures from inside the room - opening = opening.geometry.flip() - # get the rectangle in the reference plane - apt_llc = opening.lower_left_corner - apt_urc = opening.upper_right_corner min_2d = ref_plane.xyz_to_xy(apt_llc) max_2d = ref_plane.xyz_to_xy(apt_urc) - height = round(max_2d.y - min_2d.y, decimal_places) - width = round(max_2d.x - min_2d.x, decimal_places) + + height = round(max_2d.y - min_2d.y, decimal_places) + width = round(max_2d.x - min_2d.x, decimal_places) + + name = opening.identifier + corners_idm = opening_corners_to_idm( + opening_geo, ref_plane, min_2d, ang_tol, is_horizontal + ) if is_aperture: opening_idm = f'\n ((CE-WINDOW :N "{name}" :T WINDOW)\n' \ f' (:PAR :N X :V {round(min_2d.x, decimal_places)})\n' \ f' (:PAR :N Y :V {round(min_2d.y, decimal_places)})\n' \ f' (:PAR :N DX :V {width})\n' \ - f' (:PAR :N DY :V {height}))' + f' (:PAR :N DY :V {height}){corners_idm})' else: opening_idm = f'\n ((OPENING :N "{name}" :T OPENING)\n' \ f' (:PAR :N X :V {round(min_2d.x, decimal_places)})\n' \ f' (:PAR :N Y :V {round(min_2d.y, decimal_places)})\n' \ f' (:PAR :N DX :V {width})\n' \ f' (:PAR :N DY :V {height})\n' \ - f' (:RES :N OPENING-SCHEDULE :V ALWAYS_OFF))' + f' (:RES :N OPENING-SCHEDULE :V ALWAYS_OFF){corners_idm})' return opening_idm diff --git a/honeybee_idaice/shade.py b/honeybee_idaice/shade.py index 40aff9d..daeae23 100644 --- a/honeybee_idaice/shade.py +++ b/honeybee_idaice/shade.py @@ -1,6 +1,6 @@ from typing import List, Union -from honeybee.model import Shade +from honeybee.model import Shade, ShadeMesh from ladybug_geometry.geometry3d import Point3D, Face3D, Polyface3D, Mesh3D @@ -14,13 +14,14 @@ def _vertices_to_idm(vertices: List[Point3D], dec_places: int = 3) -> str: def _shade_geometry_to_idm( - geometry: Union[Face3D, Polyface3D], name: str, decimal_places: int = 3 + geometry: Union[Face3D, Polyface3D, Mesh3D], + name: str, decimal_places: int = 3 ): """Create an IDM shade block from a Ladybug geometry. Here is an example: - ((AGGREGATE :N "shade1" :T PICT3D) + ((AGGREGATE-VTK :N "shade1" :T PICT3D) (:PAR :N FILE :V "") (:PAR :N POS :V #(0 0 0.0)) (:PAR :N SHADOWING :V :TRUE) @@ -37,6 +38,8 @@ def _shade_geometry_to_idm( if isinstance(geometry, Face3D): mesh_3d = geometry.triangulated_mesh3d + elif isinstance(geometry, Mesh3D): + mesh_3d = geometry else: # it is a Polyface3D meshes = [face.triangulated_mesh3d for face in geometry.faces] @@ -52,18 +55,19 @@ def _shade_geometry_to_idm( joined_faces = ' '.join(' '.join(str(f) for f in ff) for ff in faces) shd_verts = _vertices_to_idm(vertices, decimal_places) - shade = f' ((AGGREGATE :N "{name}" :T PICT3D)\n' \ - ' (:PAR :N FILE :V "")\n' \ - ' (:PAR :N SHADOWING :V :TRUE)\n' \ - ' ((AGGREGATE :N "geom1" :T GEOM3D)\n' \ - f' (:PAR :N NPOINTS :V {vertices_count})\n' \ - f' (:PAR :N POINTS :DIM ({vertices_count} 3) :V #2A({shd_verts}))\n' \ - ' (:PAR :N CELLTYPE :V 1)\n' \ - f' (:PAR :N NCELLS :V {face_count})\n' \ - f' (:PAR :N NVERTICES :DIM ({face_count}) :V #({faces_count}))\n' \ - f' (:PAR :N TOTNVERTS :V {total_vertices})\n' \ - f' (:PAR :N VERTICES :DIM ({total_vertices}) :V #({joined_faces}))\n' \ - ' (:PAR :N PROPERTY :V #(1.0 1.0 1.0 0.699999988079071 1.0 1.0 1.0 0.5 1.0 1.0 1.0 0.0 1.0 1.0 1.0 0.0 0.0))))' + shade = f' ((AGGREGATE-VTK :N "{name}" :T PICT3D)\n' \ + ' (:PAR :N FILE :V "")\n' \ + ' (:PAR :N SHADOWING :V :TRUE)\n' \ + ' ((AGGREGATE :N "geom1" :T GEOM3D)\n' \ + f' (:PAR :N NPOINTS :V {vertices_count})\n' \ + f' (:PAR :N POINTS :DIM ({vertices_count} 3) :V #2A({shd_verts}))\n' \ + ' (:PAR :N CELLTYPE :V 1)\n' \ + f' (:PAR :N NCELLS :V {face_count})\n' \ + f' (:PAR :N NVERTICES :DIM ({face_count}) :V #({faces_count}))\n' \ + f' (:PAR :N TOTNVERTS :V {total_vertices})\n' \ + f' (:PAR :N VERTICES :DIM ({total_vertices}) :V #({joined_faces}))\n' \ + ' (:PAR :N PROPERTY :V #(1.0 1.0 1.0 0.699999988079071 1.0 1.0 1.0 0.5 1.0 1.0 1.0 0.0 1.0 1.0 1.0 0.0 0.0)))\n' \ + f' )' return shade @@ -141,4 +145,38 @@ def shades_to_idm(shades: List[Shade], tolerance: float, decimal_places: int = 3 for shades in filtered_groups.values()] ) - return f'((AGGREGATE :N ARCDATA)\n{single_shades}\n{group_shades})' + return f'{single_shades}\n{group_shades}\n' + + +def shade_meshes_to_idm(shades: List[ShadeMesh], tolerance: float, decimal_places: int = 3): + """Convert a list of Shades to a IDM string. + + Args: + shades: A list of Honeybee ShadeMeshes. + tolerance: The maximum difference between X, Y, and Z values at which point + vertices are considered distinct from one another. + decimal_places: An integer for the number of decimal places to which + coordinate values will be rounded. (Default: 3). + + Returns: + A formatted string that represents this shade in IDM format. + + """ + if not shades: + return '' + + shade_idms = [] + for shade in shades: + shade.triangulate_and_remove_degenerate_faces(tolerance) + name = '_'.join( + ( + ' '.join(shade.display_name.split()), + shade.identifier.replace('Shade_', '') + ) + ) + + shade_idms.append( + _shade_geometry_to_idm(shade.geometry, name, decimal_places) + ) + + return '\n'.join(shade_idms) diff --git a/honeybee_idaice/templates/building.idm b/honeybee_idaice/templates/building.idm index c11e667..12e3c11 100644 --- a/honeybee_idaice/templates/building.idm +++ b/honeybee_idaice/templates/building.idm @@ -725,8 +725,6 @@ (:PAR :N TRADER :V "Facility") (:PAR :N METER-ROLE :V ENVIRONMENT) (:PAR :N COLOR :V #S(RGB RED 33 GREEN 186 BLUE 200))) -((SITE-OBJECT :N SITE) - (:PAR :N SITE-AREA :V #(-100.0 -80.0 150.0 100.0))) ((SIMULATION_DATA :N SIMULATION_DATA) ((SIMULATION_PHASE :N STARTUP-PHASE) (:PAR :N FROM-TIME :V 3911846400) diff --git a/honeybee_idaice/writer.py b/honeybee_idaice/writer.py index 82f337e..bd12f0b 100644 --- a/honeybee_idaice/writer.py +++ b/honeybee_idaice/writer.py @@ -12,7 +12,7 @@ from .archive import zip_folder_to_idm from .bldgbody import section_to_idm, MAX_FLOOR_ELEVATION_DIFFERENCE, \ IDA_ICE_BUILDING_BODY_TOL -from .shade import shades_to_idm +from .shade import shades_to_idm, shade_meshes_to_idm from .face import face_to_idm, opening_to_idm, face_reference_plane @@ -390,7 +390,7 @@ def prepare_folder(bldg_name: str, out_folder: str) -> List[pathlib.Path]: def model_to_idm( model: Model, out_folder: pathlib.Path, name: str = None, max_int_wall_thickness: float = 0.40, max_adjacent_sub_face_dist: float = 0.40, - max_frame_thickness: float = 0.1, debug: bool = False): + debug: bool = False): """Translate a Honeybee model to an IDM file. Args: @@ -412,9 +412,6 @@ def model_to_idm( set this to zero (like some cases of max_int_wall_thickness), particularly when the adjacent interior geometries are not matching one another. (Default: 0.40). - max_frame_thickness: Maximum thickness of the window frame in meters. - This will be used to join any non-rectangular Apertures together in - an attempt to better rectangularize them for IDM. (Default: 0.1). debug: Set to True to not to delete the IDM folder before zipping it into a single file. """ @@ -436,10 +433,6 @@ def model_to_idm( for room in model.rooms: room.merge_coplanar_faces( model.tolerance, model.angle_tolerance, orthogonal_only=True) - # convert all apertures to be rectangular, using the model tolerances - ap_dist = max_frame_thickness if max_frame_thickness > model.tolerance \ - else model.tolerance - model.rectangularize_apertures(max_separation=ap_dist, resolve_adjacency=False) # edit the model display_names and add user_data to help with the translation adj_dist = max_adjacent_sub_face_dist \ @@ -479,6 +472,23 @@ def model_to_idm( line = line[1:] bldg.write(line) + # site object + site_idm = '((SITE-OBJECT :N SITE)\n' \ + ' (:PAR :N SITE-AREA :V #(-100.0 -80.0 150.0 100.0))\n' + bldg.write(site_idm) + has_shade = model.shade_meshes or model.shades + if has_shade: + bldg.write(' ((AGGREGATE :N ARCDATA)\n') + # add shades to building if any + shades_idm = shades_to_idm(model.shades, model.tolerance, dec_count) + bldg.write(shades_idm) + shades_idm = shade_meshes_to_idm(model.shade_meshes, model.tolerance, dec_count) + bldg.write(shades_idm) + # end of site object + if has_shade: + bldg.write('))\n') + else: + bldg.write(')\n') # create a building sections/bodies for the building sections = section_to_idm( model, max_int_wall_thickness=max_int_wall_thickness, @@ -490,9 +500,6 @@ def model_to_idm( for room in model.rooms: bldg.write(f'((CE-ZONE :N "{room.display_name}" :T ZONE))\n') - # add shades to building - shades_idm = shades_to_idm(model.shades, model.tolerance, dec_count) - bldg.write(shades_idm) bldg.write(f'\n;[end of {bldg_name}.idm]\n') # copy all the template files