Skip to content

Commit

Permalink
feat(roof): Automatically resolve roof overlaps upon translation
Browse files Browse the repository at this point in the history
This way, we can get rid of the ValidationError for overlapping roofs and just let these exist in the model.
  • Loading branch information
chriswmackey committed Aug 1, 2024
1 parent d4ec512 commit 1797e2b
Show file tree
Hide file tree
Showing 5 changed files with 220 additions and 19 deletions.
43 changes: 41 additions & 2 deletions dragonfly/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -950,7 +950,7 @@ def check_all(self, raise_exception=True, detailed=False):
msgs.append(self.check_window_parameters_valid(tol, False, detailed))
msgs.append(self.check_missing_adjacencies(False, detailed))
msgs.append(self.check_no_room2d_overlaps(tol, False, detailed))
msgs.append(self.check_no_roof_overlaps(tol, False, detailed))
msgs.append(self.check_roofs_above_rooms(tol, False, detailed))
msgs.append(self.check_all_room3d(tol, a_tol, False, detailed))
# check the extension attributes
ext_msgs = self._properties._check_extension_attr()
Expand Down Expand Up @@ -1202,12 +1202,51 @@ def check_no_room2d_overlaps(
return msg
return ''

def check_no_roof_overlaps(
def check_roofs_above_rooms(
self, tolerance=None, raise_exception=True, detailed=False):
"""Check that geometries of RoofSpecifications do not overlap with one another.
Overlaps make the Roof geometry unusable for translation to Honeybee.
Args:
tolerance: The minimum distance that two Roof geometries can overlap
with one another and still be considered valid. If None, the Model
tolerance will be used. (Default: None).
raise_exception: Boolean to note whether a ValueError should be raised
if overlapping geometries are found. (Default: True).
detailed: Boolean for whether the returned object is a detailed list of
dicts with error info or a string with a message. (Default: False).
Returns:
A string with the message or a list with a dictionary if detailed is True.
"""
tolerance = self.tolerance if tolerance is None else tolerance
bldg_ids = []
for bldg in self._buildings:
for story in bldg._unique_stories:
ov_msg = story.check_roofs_above_rooms(tolerance, False, detailed)
if ov_msg:
if detailed:
bldg_ids.extend(ov_msg)
else:
bldg_ids.append('{}\n {}'.format(bldg.full_id, ov_msg))
if detailed:
return bldg_ids
if bldg_ids != []:
msg = 'The following Buildings have overlaps in their roof geometry' \
':\n{}'.format('\n'.join(bldg_ids))
if raise_exception:
raise ValueError(msg)
return msg
return ''

def check_no_roof_overlaps(
self, tolerance=None, raise_exception=True, detailed=False):
"""Check that geometries of RoofSpecifications do not overlap with one another.
This is not a requirement for the Model to be valid but it is sometimes
useful to check.
Args:
tolerance: The minimum distance that two Roof geometries can overlap
with one another and still be considered valid. If None, the Model
Expand Down
120 changes: 106 additions & 14 deletions dragonfly/roof.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,107 @@ def max(self):
"""
return self._calculate_max(self._geometry)

@property
def min_height(self):
"""Get lowest Z-value of the roof geometry."""
min_z = self._geometry[0].min.z
for r_geo in self._geometry[1:]:
if r_geo.min.z < min_z:
min_z = r_geo.min.z
return min_z

@property
def max_height(self):
"""Get highest Z-value of the roof geometry."""
max_z = self._geometry[0].max.z
for r_geo in self._geometry[1:]:
if r_geo.max.z > max_z:
max_z = r_geo.max.z
return max_z

def resolved_geometry(self, tolerance=0.01):
"""Get a version of this object's geometry with all overlaps in plan resolved.
In the case of overlaps, the roof geometry that has the lowest average
z-value for the overlap will become the "correct" one that actually
bounds the room geometry.
Args:
tolerance: The minimum distance that two Roof geometries can overlap
with one another and still be considered distinct. (Default: 0.01,
suitable for objects in meters).
Returns:
A list of Face3D that have no overlaps in plan.
"""
# first check to see if an overlap is possible
if len(self._geometry) == 1:
return self._geometry
# loop through the geometries and test for any overlaps
proj_dir = Vector3D(0, 0, 1)
planes = [pl for _, pl in sorted(zip(self.boundary_geometry_2d, self.planes),
key=lambda pair: pair[0].area, reverse=True)]
geo_2d = sorted(self.boundary_geometry_2d, key=lambda x: x.area, reverse=True)
gei = list(range(len(geo_2d)))
overlap_count = 0
for i, (poly_1, pln_1) in enumerate(zip(geo_2d, planes)):
try:
for poly_2, pln_2, j in zip(geo_2d[i + 1:], planes[i + 1:], gei[i + 1:]):
if poly_1.polygon_relationship(poly_2, tolerance) == 0:
# resolve the overlap between the polygons
overlap_count += 1
try:
overlap_polys = poly_1.boolean_intersect(poly_2, tolerance)
except Exception: # tolerance is likely not set correctly
continue
for o_poly in overlap_polys:
o_face_1, o_face_2 = [], []
for pt in o_poly.vertices:
pt1 = pln_1.project_point(Point3D(pt.x, pt.y), proj_dir)
pt2 = pln_2.project_point(Point3D(pt.x, pt.y), proj_dir)
o_face_1.append(pt1)
o_face_2.append(pt2)
o_face_1 = Face3D(o_face_1, plane=pln_1)
o_face_2 = Face3D(o_face_2, plane=pln_2)
if o_face_1.center.z > o_face_2.center.z:
# remove the overlap from the first polygon
try:
new_polys = poly_1.boolean_difference(o_poly, tolerance)
poly_1 = new_polys[0] if len(new_polys) == 1 else poly_1
geo_2d[i] = poly_1
except Exception: # tolerance is likely not set correctly
pass
else: # remove the overlap from the second polygon
try:
new_polys = poly_2.boolean_difference(o_poly, tolerance)
poly_2 = new_polys[0] if len(new_polys) == 1 else poly_2
geo_2d[j] = poly_2
except Exception: # tolerance is likely not set correctly
pass
except IndexError:
pass # we have reached the end of the list
# if any overlaps were found, rebuild the 3D roof geometry
if overlap_count != 0:
resolved_geo = []
for r_poly, r_pln in zip(geo_2d, planes):
r_face = []
for pt2 in r_poly.vertices:
pt3 = r_pln.project_point(Point3D(pt2.x, pt2.y), proj_dir)
r_face.append(pt3)
resolved_geo.append(Face3D(r_face, plane=r_pln))
return resolved_geo
return self._geometry # no overlaps in the geometry; just return as is

def overlap_count(self, tolerance=0.01):
"""Get the number of times that the Roof geometries overlap with one another.
This should be zero for the RoofSpecification to be valid.
This should be zero for the RoofSpecification to be be translated to
Honeybee without any loss of geometry.
Args:
tolerance: The minimum distance that two Roof geometries can overlap
with one another and still be considered valid. Default: 0.01,
suitable for objects in meters.
with one another and still be considered distinct. (Default: 0.01,
suitable for objects in meters).
Returns:
An integer for the number of times that the roof geometries overlap
Expand Down Expand Up @@ -258,7 +350,7 @@ def align(self, line_ray, distance, tolerance=0.01):
else:
msg = 'Expected Ray2D or LineSegment2D. Got {}.'.format(type(line_ray))
raise TypeError(msg)

# get the polygons and intersect them for matching segments
polygons, planes = self.boundary_geometry_2d, self.planes
poly_ridge_info = self._compute_ridge_line_info(tolerance)
Expand Down Expand Up @@ -414,11 +506,11 @@ def _calculate_min(geometry_objects):
"""
min_pt = [geometry_objects[0].min.x, geometry_objects[0].min.y]

for room in geometry_objects[1:]:
if room.min.x < min_pt[0]:
min_pt[0] = room.min.x
if room.min.y < min_pt[1]:
min_pt[1] = room.min.y
for r_geo in geometry_objects[1:]:
if r_geo.min.x < min_pt[0]:
min_pt[0] = r_geo.min.x
if r_geo.min.y < min_pt[1]:
min_pt[1] = r_geo.min.y

return Point2D(min_pt[0], min_pt[1])

Expand All @@ -431,11 +523,11 @@ def _calculate_max(geometry_objects):
"""
max_pt = [geometry_objects[0].max.x, geometry_objects[0].max.y]

for room in geometry_objects[1:]:
if room.max.x > max_pt[0]:
max_pt[0] = room.max.x
if room.max.y > max_pt[1]:
max_pt[1] = room.max.y
for r_geo in geometry_objects[1:]:
if r_geo.max.x > max_pt[0]:
max_pt[0] = r_geo.max.x
if r_geo.max.y > max_pt[1]:
max_pt[1] = r_geo.max.y

return Point2D(max_pt[0], max_pt[1])

Expand Down
57 changes: 56 additions & 1 deletion dragonfly/story.py
Original file line number Diff line number Diff line change
Expand Up @@ -1480,11 +1480,55 @@ def check_no_room2d_overlaps(
raise ValueError(full_msg)
return full_msg

def check_roofs_above_rooms(
self, tolerance=0.01, raise_exception=True, detailed=False):
"""Check that geometries of RoofSpecifications all lie above the Room2D geometry.
Roofs that lie below the Room2Ds will result in invalid Honeybee Rooms
with self-intersecting walls.
Args:
tolerance: The minimum distance between coordinate values that is
considered a meaningful difference. (Default: 0.01, suitable
for objects in meters).
raise_exception: Boolean to note whether a ValueError should be raised if
roof geometries are found below the Room2D geometries. (Default: True).
detailed: Boolean for whether the returned object is a detailed list of
dicts with error info or a string with a message. (Default: False).
Returns:
A string with the message or a list with a dictionary if detailed is True.
"""
# find the number of overlaps in the Roof specification
msgs = []
if self.roof is not None:
roof_min = self.roof.min_height
room_max = self.room_2ds[0].floor_geometry.max.z
for room in self.room_2ds[1:]:
if room.floor_geometry.max.z > room_max:
room_max = room.floor_geometry.max.z
if roof_min < room_max - tolerance:
msg = 'Roof geometry of story "{}" extends down to a height of {}, ' \
'which is lower than the height of the room floor plates at {}. ' \
'This may result in invalid room volumes.'.format(
self.display_name, roof_min, room_max)
msg = self._validation_message_child(
msg, self.roof, detailed, '100105', error_type='Invalid Roof')
msgs.append(msg)
# report any errors
if detailed:
return msgs
full_msg = '\n '.join(msgs)
if raise_exception and len(msgs) != 0:
raise ValueError(full_msg)
return full_msg

def check_no_roof_overlaps(
self, tolerance=0.01, raise_exception=True, detailed=False):
"""Check that geometries of RoofSpecifications do not overlap with one another.
Overlaps make the Roof geometry unusable for translation to Honeybee.
This is not required for the Story to be valid but it is sometimes
useful to check.
Args:
tolerance: The minimum distance that two Roof geometries can overlap
Expand Down Expand Up @@ -1551,6 +1595,14 @@ def to_honeybee(self, use_multiplier=True, add_plenum=False, tolerance=0.01,
# set up the multiplier
mult = self.multiplier if use_multiplier else 1

# if this story has any overlaps, resolve them before translation
original_roof = None
if self.roof is not None:
original_roof = self.roof
res_roof_geo = self.roof.resolved_geometry(tolerance)
res_roof = RoofSpecification(res_roof_geo)
self.roof = res_roof

# convert all of the Room2Ds to honeybee Rooms
hb_rooms = []
adjacencies = []
Expand Down Expand Up @@ -1594,6 +1646,9 @@ def to_honeybee(self, use_multiplier=True, add_plenum=False, tolerance=0.01,
adj_set.add(face.identifier)
break
break
# put back the original roof to avoid mutating the story
if original_roof is not None:
self.roof = original_roof
return hb_rooms

def to_dict(self, abridged=False, included_prop=None):
Expand Down
1 change: 1 addition & 0 deletions tests/json/roof_with_overlap.dfjson

Large diffs are not rendered by default.

18 changes: 16 additions & 2 deletions tests/model_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,8 @@ def test_to_honeybee_roof_with_dormer():
assert upper_story.roof is not None

hb_models = model.to_honeybee('District', None, False, tolerance=0.01)
assert len(hb_models[0].rooms[0].shades) == 2
assert len(hb_models[0].rooms[0].shades) == 2 or \
len(hb_models[0].rooms[0].shades) == 0

# try moving the dormer to see if it still translates successfully
roof_polys = upper_story.roof.boundary_geometry_2d
Expand All @@ -917,7 +918,20 @@ def test_to_honeybee_roof_with_dormer():
upper_story.roof.update_geometry_2d(Polygon2D(new_poly_pts), i)

hb_models = model.to_honeybee('District', None, False, tolerance=0.01)
assert len(hb_models[0].rooms[0].shades) == 2
assert len(hb_models[0].rooms[0].shades) == 2 or \
len(hb_models[0].rooms[0].shades) == 0


def test_to_honeybee_roof_with_overlap():
"""Test to_honeybee with an overlapping roof to ensure all exceptions are caught."""
model_file = './tests/json/roof_with_overlap.dfjson'
model = Model.from_file(model_file)
upper_story = model.buildings[0][-1]
assert upper_story.roof is not None

hb_models = model.to_honeybee('District', None, False, tolerance=0.01)
assert len(hb_models) == 1
assert len(hb_models[0].rooms[0].roof_ceilings) > 2


def test_to_honeybee_invalid_roof_1():
Expand Down

0 comments on commit 1797e2b

Please sign in to comment.