import math
import pathlib
import re
from typing import Iterator, List, Tuple, Union, Dict
import uuid
from ladybug_geometry.geometry3d import Face3D, Point3D, Vector3D, \
Plane, Mesh3D
from ladybug_geometry.geometry2d import Polygon2D, Point2D, Vector2D
from ladybug_geometry.geometry2d.polygon import closest_point2d_on_line2d
from honeybee.model import Model, Shade, Room, Face, Aperture, Door, \
AirBoundary, ShadeMesh
from honeybee.boundarycondition import Outdoors, Ground
from honeybee.typing import clean_string, clean_and_id_ep_string
from .types import GEM_TYPES
PI = math.pi
Z_AXIS = Vector3D(0, 0, 1)
ROOF_ANGLE_TOLERANCE = math.radians(10)
MODEL_TOLERANCE = 0.001
def _gem_object_type(info: str, keyword: str = 'IES') -> GEM_TYPES:
"""Get GEM object type from info."""
type_ = int(re.findall(r'^TYPE\n(\d*)', info, re.MULTILINE)[0])
subtype = int(re.findall(r'^SUBTYPE\n(\d*)', info, re.MULTILINE)[0])
category = int(re.findall(r'^CATEGORY\n(\d*)', info, re.MULTILINE)[0])
return GEM_TYPES.from_info(
category=category, type_=type_, subtype=subtype, keyword=keyword
)
def _add_user_date(face: Union[Face, Shade], user_data: Dict):
"""Add user data to a face or a shade object."""
if not user_data:
return
if face.user_data:
# the dictionary exists
face.user_data.update(user_data)
else:
face.user_data = user_data
def _get_id(display_name):
"""Extract the id from display name.
In version 2023 the ID is included in the GEM file as inside [] at the end of the
name.
"""
id_ = re.findall(r'\s\[(.*)\]$', display_name, re.MULTILINE)
if id_:
return id_[0]
else:
return None
def _update_name(obj: Union[Shade, Room], display_name: str, count: int = None):
"""Add group id and display name to objects."""
if not isinstance(obj, Room):
identifier = \
clean_string(display_name) if count \
else clean_string(f'{display_name}-{count}')
_add_user_date(
face=obj, user_data={'__group_id__': identifier}
)
id_ = _get_id(display_name)
if id_:
obj.identifier = f'{id_}-{count}' if count else id_
obj.display_name = display_name.replace(f' [{id_}]', '')
else:
obj.display_name = display_name
def _create_shade(
boundary: List[Point3D], holes: List[Point3D] = None,
is_detached: bool = True, user_data: Dict = None):
"""Create a Honeybee Shade object"""
geometry = Face3D(boundary, holes=holes)
face = Shade(
str(uuid.uuid4()), geometry=geometry,
is_detached=is_detached
)
_add_user_date(face, user_data)
return face
def _opening_from_ies(geometry: Face3D, content: Iterator) -> Tuple[List[Point3D], int]:
"""Translate an opening from gem format.
Args:
parent_geo: Geometry of the parent object.
content: A GEM string
Returns:
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.
"""
if geometry.plane.n.z in (1, -1):
# horizontal faces
origin = geometry.upper_right_corner
else:
origin = geometry.lower_left_corner
# This is how the next line looks
# 5 2
ver_count, opening_type = [int(float(v)) for v in next(content).split()]
# calculate the vertices from X, Y values
# 0.000000 0.906100
# 0.000000 0.000000
# 10.373100 0.000000
tolerance = MODEL_TOLERANCE * 5
boundary_2d: Polygon2D = geometry.boundary_polygon2d
offset_boundary_2d = boundary_2d.offset(tolerance)
origin_2d = geometry.plane.xyz_to_xy(origin)
# create vertices in 2D
opening_vertices = []
opening_vertices_2d = []
for _ in range(ver_count):
cnt = next(content).split()
if len(cnt) == 2:
x_m, y_m = [float(v) for v in cnt]
elif len(cnt) == 3:
# Translucent shade
x_m, y_m, opacity = [float(v) for v in cnt]
vertex_2d = Point2D(origin_2d.x - x_m, origin_2d.y - y_m)
vertex = geometry.plane.xy_to_xyz(vertex_2d)
on_segments = []
for segment in boundary_2d.segments:
close_pt = closest_point2d_on_line2d(vertex_2d, segment)
if vertex_2d.distance_to_point(close_pt) <= tolerance:
on_segments.append((segment, close_pt))
if not on_segments or opening_type == 2:
# for holes don't move the vertex
pass
elif len(on_segments) == 1:
# it is an edge
vector: Vector2D = on_segments[0][0].v
v1 = vector.rotate(PI / 2).normalize() * tolerance
v2 = vector.rotate(-PI / 2).normalize() * tolerance
v = vertex_2d.move(v1)
if boundary_2d.is_point_inside_check(v):
vertex_2d = v
else:
v = vertex_2d.move(v2)
if boundary_2d.is_point_inside_check:
vertex_2d = v
elif len(on_segments) == 2:
# The point is adjacent to a corner. Find the closest point to the offset boundary
dist_col = []
for seg in offset_boundary_2d.segments:
cp = seg.closest_point(vertex_2d)
dist = cp.distance_to_point(vertex_2d)
dist_col.append([dist, cp])
points_sorted = sorted(dist_col, key=lambda x: x[0])
vertex_2d = points_sorted[0][1]
else:
# this should not happen!
print(f'{vertex} is adjacent to more than 2 edges of the same polygon.')
vertex = geometry.plane.xy_to_xyz(vertex_2d)
opening_vertices.append(vertex)
opening_vertices_2d.append(vertex_2d)
org_pl = Polygon2D(opening_vertices_2d)
opening_area = org_pl.area
return opening_type, opening_vertices, opening_vertices_2d, opening_area
def _create_tree(info: str, tree_type=1) -> Shade:
"""Create a Tree from an IES Tree."""
values = [float(v) for v in info.strip().split()]
assert len(values) == 8, 'Length of data for tree is not 8 segments.'
# calculate tree geometry
x, y, z, x_scale, y_scale, z_scale, xy_rotation, yz_rotation = values
x_scale *= 3
y_scale *= 3
z_scale *= 8
base = Point3D(x, y, z)
# move the base plane for half the x_scale
x_base = Plane(o=base, n=Vector3D(0, -1, 0), x=Vector3D(1, 0, 0))
x_base = x_base.move(Vector3D(-x_scale / 2, 0, 0))
y_base = Plane(o=base, n=Vector3D(1, 0, 0), x=Vector3D(0, 1, 0))
y_base = y_base.move(Vector3D(0, -y_scale / 2, 0))
geometries = [
Face3D.from_rectangle(x_scale, z_scale, x_base),
Face3D.from_rectangle(y_scale, z_scale, y_base)
]
geos = []
for geometry in geometries:
if yz_rotation:
axis = Vector3D(-1, 0, 0)
geometry = geometry.rotate(axis, math.radians(yz_rotation), base)
if xy_rotation:
geometry = geometry.rotate_xy(math.radians(xy_rotation), base)
geos.append(geometry)
tree_0 = _create_shade(
geos[0].lower_left_counter_clockwise_vertices,
user_data={
'__gem_type__': 'tree',
'__gem_tree_type__': tree_type,
'__ies_import__': True
}
)
tree_1 = _create_shade(
geos[1].lower_left_counter_clockwise_vertices,
user_data={
'__gem_type__': 'tree',
'__gem_tree_type__': tree_type
}
)
return tree_0, tree_1
def _create_pv(info: str) -> Shade:
"""Create a PV panel from GEM PV panel."""
values = [float(v) for v in info.strip().split()]
assert len(values) == 7, 'Length of data for PV is not 7 segments.'
# calculate PV geometry
x, y, z, width, height, xy_rotation, yz_rotation = values
base = Point3D(x, y, z)
base_plane = Plane(o=base)
geometry = Face3D.from_rectangle(width, -height, base_plane)
if yz_rotation:
axis = Vector3D(-1, 0, 0)
geometry = geometry.rotate(axis, math.radians(yz_rotation), base)
if xy_rotation:
geometry = geometry.rotate_xy(math.radians(xy_rotation), base)
pv = _create_shade(
geometry.lower_left_counter_clockwise_vertices,
user_data={'__gem_type__': 'pv'}
)
return pv
def _parse_gem_segment(
segment: str, ignore_shade_mesh=False) -> Union[Room, ShadeMesh, Shade]:
"""Parse a segment of the GEM file.
Each segment has the information for a room or a shade object.
"""
for keyword in ['IES', 'LAN', 'PVP']:
if keyword in segment:
info, segments = re.split(f'\n{keyword} ', segment)
break
else:
raise ValueError(
'There is a segment with an unsupported type in the input GEM file. '
'Reach out to us with a copy of the GEM file and the information below:\n'
f'{segment}'
)
gem_type = _gem_object_type(info=info, keyword=keyword)
# remove empty lines if any
content = iter(lin for lin in segments.split('\n') if lin.strip())
display_name = next(content)
cleaned_display_name = clean_string(display_name)
identifier = clean_and_id_ep_string(cleaned_display_name)
if gem_type == GEM_TYPES.PV:
pv_info = next(content)
face = _create_pv(pv_info)
_update_name(face, display_name)
return [face]
elif gem_type == GEM_TYPES.Tree:
tree_type = next(content)
assert tree_type.startswith('2D Tree'), \
f'{tree_type} is not currently supported.'
tree_type = int(tree_type.split()[-1])
tree_info = next(content)
faces = _create_tree(tree_info, tree_type=tree_type)
for count, face in enumerate(faces):
_update_name(face, display_name, count)
return faces
faces = []
# everything else
ver_count, face_count = [int(v) for v in next(content).split()]
vertices = [
Point3D(*[float(v) for v in next(content).split()])
for _ in range(ver_count)
]
# create a shade mesh for shades with multiple faces
if not ignore_shade_mesh \
and gem_type == GEM_TYPES.ContextBuilding \
and face_count > 1:
faces = []
for _ in range(face_count):
boundary = tuple(int(i) - 1 for i in next(content).split()[1:])
opening_count = int(next(content)) # pass the line for the opening count
if opening_count > 0 or len(boundary) > 4:
# there is a hole in the shade or the face has more than 4 edges
# use the old method of using faces instead
return _parse_gem_segment(segment, True)
faces.append(boundary)
mesh_geometry = Mesh3D(vertices=vertices, faces=faces)
mesh = ShadeMesh(identifier=identifier, geometry=mesh_geometry, is_detached=True)
_update_name(mesh, display_name)
return mesh
# create faces
for _ in range(face_count):
apertures = []
doors = []
holes = []
holes_2d = []
boundary = [vertices[int(i) - 1] for i in next(content).split()[1:]]
boundary_geometry = Face3D(boundary, enforce_right_hand=False)
boundary_geometry_polygon2d = boundary_geometry.boundary_polygon2d
boundary_area = boundary_geometry.area
holes_area = 0
opening_count = int(next(content))
for _ in range(opening_count):
opening_type, opening_vertices, opening_vertices_2d, opening_area = \
_opening_from_ies(boundary_geometry, content)
if opening_type == 0:
# create an aperture
aperture_geo = Face3D(opening_vertices)
aperture = Aperture(str(uuid.uuid4()), aperture_geo)
apertures.append(aperture)
elif opening_type == 1:
# create a door
door_geo = Face3D(opening_vertices)
door = Door(str(uuid.uuid4()), door_geo)
doors.append(door)
elif opening_type == 2:
# create a hole
holes_area += opening_area
holes.append(opening_vertices)
holes_2d.append(Polygon2D(opening_vertices_2d))
else:
raise ValueError(f'Unsupported opening type: {opening_type}')
if gem_type == GEM_TYPES.Space:
# A model face
geometry = Face3D(boundary)
face = Face(str(uuid.uuid4()), geometry=geometry)
if apertures or doors:
# change the boundary condition if it is set to ground
if isinstance(face.boundary_condition, Ground):
print(
'Changing boundary condition from Ground to Outdoors for '
f'{face.display_name} in {display_name} [{identifier}].'
)
face.boundary_condition = Outdoors()
face.add_apertures(apertures)
face.add_doors(doors)
if holes:
# add an AirBoundary to cover the hole
if holes_area >= 0.98 * boundary_area:
# the face is mostly created from holes
# replace the parent face with an face from type AirBoundary
if len(holes) == 1:
# the entire face is created from holes
face.type = AirBoundary()
faces.append(face)
continue
# there are multiple air boundaries. create an AirBoundary for each.
for hole in holes_2d:
hole = boundary_geometry_polygon2d.snap_to_polygon(
hole, MODEL_TOLERANCE * 5)
# map the hole back to 3D
hole = [boundary_geometry.plane.xy_to_xyz(ver) for ver in hole]
hole_geo = Face3D(hole)
hole_face = Face(
str(uuid.uuid4()), geometry=hole_geo, type=AirBoundary()
)
faces.append(hole_face)
continue
# only part of the face is created from holes.
# 1. try to snap them to the face
# 2. separate holes from side air boundaries
holes_2d_snapped = [
boundary_geometry_polygon2d.snap_to_polygon(hole, MODEL_TOLERANCE * 5)
for hole in holes_2d
]
holes_3d_snapped = [
Face3D([boundary_geometry.plane.xy_to_xyz(v) for v in hole])
for hole in holes_2d_snapped
]
base_faces = boundary_geometry.coplanar_difference(
holes_3d_snapped, tolerance=MODEL_TOLERANCE, angle_tolerance=0.01
)
if isinstance(base_faces, Face3D):
# change the base face to a list if the difference is a single face
base_faces = [base_faces]
base_faces_holes = [[] for _ in base_faces]
holes_tracker = []
for hole_count, hole_geo in enumerate(holes_3d_snapped):
for count, base_face in enumerate(base_faces):
if not base_face.holes:
continue
for f_hole in base_face.holes:
f_hole_geo = Face3D(f_hole)
if hole_geo.center.distance_to_point(f_hole_geo.center) <= \
MODEL_TOLERANCE * 5:
# this hole is inside the face
base_faces_holes[count].append(f_hole_geo)
holes_tracker.append(hole_count)
break
else:
if hole_count in holes_tracker:
continue
# the hole is not inside any of the faces
hole_face = Face(
str(uuid.uuid4()), geometry=hole_geo, type=AirBoundary()
)
faces.append(hole_face)
# create holes
holes_flattened = [h for holes in base_faces_holes for h in holes]
for hole_geo in holes_flattened:
hole_face = Face(
str(uuid.uuid4()), geometry=hole_geo, type=AirBoundary()
)
# add a key to user data to skip the face when translating
# back from HBJSON to GEM
hole_face.user_data = {'__ies_import__': True}
faces.append(hole_face)
# add base faces
for base_face in base_faces:
face = Face(str(uuid.uuid4()), geometry=base_face)
faces.append(face)
continue
elif gem_type in (
GEM_TYPES.ContextBuilding, GEM_TYPES.Shade, GEM_TYPES.Shade_2):
is_detached = True if gem_type == GEM_TYPES.ContextBuilding else False
face = _create_shade(boundary, holes, is_detached)
_update_name(face, display_name)
elif gem_type == GEM_TYPES.TranslucentShade:
# ignore the hole. GEM has a strange way of building translucent shades
face = _create_shade(
boundary=boundary, is_detached=False,
user_data={'__gem_type__': 'translucent_shade'}
)
_update_name(face, display_name)
elif gem_type == GEM_TYPES.Topography:
# Topography
face = _create_shade(
boundary=boundary, holes=holes, is_detached=True,
user_data={'__gem_type__': 'topography'}
)
_update_name(face, display_name)
faces.append(face)
if gem_type == GEM_TYPES.Space:
room = Room(identifier, faces=faces)
_update_name(room, display_name)
return room
else:
return faces
[docs]
def model_from_gem(
gem_str: str, model_id: str = 'Unnamed', model_name: str = None
) -> Model:
"""Create a Honeybee Model from the string contents of a VE GEM file.
Args:
gem_str: Text string representation of the contents of a GEM file.
model_id: Text string to be applied as the Model identifier. Typically,
this is derived from the GEM file name. (Default: Unnamed).
model_name: Text string to be applied as the Model identifier. If None,
this will be the same as the model_id. (Default: None).
Returns:
A Honeybee Model derived from the GEM file contents.
"""
# parse the Rooms, Shades and ShadeMeshes
segments = gem_str.split('\nLAYER')[1:]
parsed_objects = [_parse_gem_segment(segment) for segment in segments]
rooms = []
shades = []
shade_meshes = []
for r in parsed_objects:
if isinstance(r, Room):
rooms.append(r)
elif isinstance(r, ShadeMesh):
shade_meshes.append(r)
else:
shades.extend(r)
# create the Model
model = Model(
clean_string(model_id), rooms=rooms, orphaned_shades=shades,
shade_meshes=shade_meshes, units='Meters', tolerance=0.0001
)
if model_name is not None:
model.display_name = model_name
return model
[docs]
def model_from_ies(gem: str) -> Model:
"""Create a Honeybee Model from a VE GEM file.
Args:
gem: String for the path to a VE GEM file.
Returns:
A Honeybee Model derived from the GEM file contents.
"""
# load the contents of the GEM file
gem_file = pathlib.Path(gem)
file_contents = gem_file.read_text(encoding='utf-8')
# return the Honeybee Model
return model_from_gem(file_contents, clean_string(gem_file.stem), gem_file.stem)