Skip to content

Commit

Permalink
fix(validate): Add a HB native check for colliding room volumes
Browse files Browse the repository at this point in the history
  • Loading branch information
chriswmackey authored and Chris Mackey committed Sep 5, 2023
1 parent 25fc1ad commit c414224
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 6 deletions.
19 changes: 15 additions & 4 deletions honeybee/cli/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ def validate():
@validate.command('model')
@click.argument('model-json', type=click.Path(
exists=True, file_okay=True, dir_okay=False, resolve_path=True))
@click.option(
'--check-all/--room-overlaps', ' /-ro', help='Flag to note whether the output '
'validation report should validate all possible issues with the model or only '
'the Room collisions should be checked.', default=True, show_default=True)
@click.option(
'--plain-text/--json', ' /-j', help='Flag to note whether the output validation '
'report should be formatted as a JSON object instead of plain text. If set to JSON, '
Expand All @@ -33,7 +37,7 @@ def validate():
'--output-file', '-f', help='Optional file to output the full report '
'of the validation. By default it will be printed out to stdout',
type=click.File('w'), default='-')
def validate_model(model_json, plain_text, output_file):
def validate_model(model_json, check_all, plain_text, output_file):
"""Validate all properties of a Model file against the Honeybee schema.
This includes checking basic compliance with the 5 rules of honeybee geometry
Expand Down Expand Up @@ -68,7 +72,10 @@ def validate_model(model_json, plain_text, output_file):
parsed_model = Model.from_hbjson(model_json)
click.echo('Re-serialization passed.')
# perform several other checks for geometry rules and others
report = parsed_model.check_all(raise_exception=False, detailed=False)
if check_all:
report = parsed_model.check_all(raise_exception=False, detailed=False)
else:
report = parsed_model.check_room_volume_collisions(raise_exception=False)
click.echo('Model checks completed.')
# check the report and write the summary of errors
if report == '':
Expand All @@ -86,8 +93,12 @@ def validate_model(model_json, plain_text, output_file):
try:
parsed_model = Model.from_hbjson(model_json)
out_dict['fatal_error'] = ''
out_dict['errors'] = \
parsed_model.check_all(raise_exception=False, detailed=True)
if check_all:
errors = parsed_model.check_all(raise_exception=False, detailed=True)
else:
errors = parsed_model.check_room_volume_collisions(
raise_exception=False, detailed=True)
out_dict['errors'] = errors
out_dict['valid'] = True if len(out_dict['errors']) == 0 else False
except Exception as e:
out_dict['fatal_error'] = str(e)
Expand Down
41 changes: 39 additions & 2 deletions honeybee/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ def from_rectangle_plan(
"""Create a model with a rectangular floor plan.
Note that the resulting Rooms in the model won't have any windows or solved
adjacencies. These can be added by using the Room.solve_adjacency method
adjacencies. These can be added by using the Model.solve_adjacency method
and the various Face.apertures_by_XXX methods.
Args:
Expand Down Expand Up @@ -563,7 +563,7 @@ def from_l_shaped_plan(
"""Create a model with an L-shaped floor plan.
Note that the resulting Rooms in the model won't have any windows or solved
adjacencies. These can be added by using the Room.solve_adjacency method
adjacencies. These can be added by using the Model.solve_adjacency method
and the various Face.apertures_by_XXX methods.
Args:
Expand Down Expand Up @@ -1823,6 +1823,7 @@ def check_all(self, raise_exception=True, detailed=False):
msgs.append(self.check_rooms_solid(tol, ang_tol, False, detailed))

# perform checks related to adjacency relationships
msgs.append(self.check_room_volume_collisions(tol, False, detailed))
msgs.append(self.check_missing_adjacencies(False, detailed))
msgs.append(self.check_matching_adjacent_areas(tol, False, detailed))
msgs.append(self.check_all_air_boundaries_adjacent(False, detailed))
Expand Down Expand Up @@ -2139,6 +2140,42 @@ def check_rooms_solid(self, tolerance=None, angle_tolerance=None,
raise ValueError(full_msg)
return full_msg

def check_room_volume_collisions(
self, tolerance=None, raise_exception=True, detailed=False):
"""Check whether the Model's rooms collide with one another beyond the tolerance.
Args:
tolerance: tolerance: The maximum difference between x, y, and z values
at which face vertices are considered equivalent. If None, the Model
tolerance will be used. (Default: None).
raise_exception: Boolean to note whether a ValueError should be raised
if the room geometry does not form a closed solid. (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.
"""
# set default values
tolerance = self.tolerance if tolerance is None else tolerance
detailed = False if raise_exception else detailed
# group the rooms by their floor heights to enable collision checking
room_groups, _ = Room.group_by_floor_height(self.rooms, tolerance)
# loop trough the groups and detect collisions
msgs = []
for rg in room_groups:
msg = Room.check_room_volume_collisions(rg, tolerance, detailed)
if detailed:
msgs.extend(msg)
elif msg != '':
msgs.append(msg)
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_missing_adjacencies(self, raise_exception=True, detailed=False):
"""Check that all Faces Apertures, and Doors have adjacent objects in the model.
Expand Down
78 changes: 78 additions & 0 deletions honeybee/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -1961,6 +1961,84 @@ def stories_by_floor_height(rooms, min_difference=2.0):
room.story = story_name
return story_names

@staticmethod
def check_room_volume_collisions(rooms, tolerance=0.01, detailed=False):
"""Check whether the volumes of Rooms collide with one another beyond tolerance.
At the moment, this method only checks for the case where coplanar Floor
Faces of different Rooms overlap with one another, which clearly indicates
that there is definitely a collision between the Room volumes. In the
future, this method may be amended to sense more complex cases of
colliding Room volumes. For now, it is designed to only detect the most
common cases.
Args:
rooms: A list of rooms that will be checked for volumetric collisions.
For this method to run most efficiently, these input Rooms should
be at the same horizontal floor level. The Room.group_by_floor_height()
method can be used to group the Rooms of a model according to their
height before running this method.
tolerance: The minimum difference between the coordinate values of two
vertices at which they can be considered equivalent. (Default: 0.01,
suitable for objects in meters.
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.
"""
# create Polygon2Ds from the floors of the rooms
polys = [
[(Polygon2D(Point2D(p.x, p.y) for p in flr.vertices), flr.geometry[0].z)
for flr in room.floors if flr.geometry.is_horizontal(tolerance)]
for room in rooms
]

# find the number of overlaps across the Rooms
msgs = []
for i, (room_1, polys_1) in enumerate(zip(rooms, polys)):
overlap_rooms = []
if len(polys_1) == 0:
continue
try:
for room_2, polys_2 in zip(rooms[i + 1:], polys[i + 1:]):
collision_found = False
for ply_1, z1 in polys_1:
if collision_found:
break
for ply_2, z2 in polys_2:
if collision_found:
break
if abs(z1 - z2) < tolerance:
if Polygon2D.overlapping_bounding_rect(
ply_1, ply_2, tolerance):
if ply_1.polygon_relationship(ply_2, tolerance) >= 0:
overlap_rooms.append(room_2)
collision_found = True
break
except IndexError:
pass # we have reached the end of the list

# of colliding rooms were found, create error messages
if len(overlap_rooms) != 0:
for room_2 in overlap_rooms:
msg = 'Room "{}" has a volume that collides with the volume ' \
'of Room "{}" more than the tolerance ({}).'.format(
room_1.display_name, room_2.display_name, tolerance)
msg = Room._validation_message_child(
msg, room_1, detailed, '000108',
error_type='Colliding Room Volumes')
if detailed:
msg['element_id'].append(room_2.identifier)
msg['element_name'].append(room_2.display_name)
msg['parents'].append(msg['parents'][0])
msgs.append(msg)
# report any errors
if detailed:
return msgs
full_msg = '\n '.join(msgs)
return full_msg

@staticmethod
def grouped_horizontal_boundary(
rooms, min_separation=0, tolerance=0.01, floors_only=True):
Expand Down
12 changes: 12 additions & 0 deletions tests/cli_validate_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,15 @@ def test_validate_mismatched_adjacency():
assert not valid_report['valid']
assert len(valid_report['errors']) == 1
assert len(valid_report['errors'][0]['element_id']) == 2


def test_colliding_room_volumes():
incorrect_input_model = './tests/json/colliding_room_volumes.hbjson'
if (sys.version_info >= (3, 7)):
runner = CliRunner()
result = runner.invoke(validate_model, [incorrect_input_model, '--json'])
outp = result.output
valid_report = json.loads(outp)
assert not valid_report['valid']
assert len(valid_report['errors']) == 1
assert len(valid_report['errors'][0]['element_id']) == 2
1 change: 1 addition & 0 deletions tests/json/colliding_room_volumes.hbjson

Large diffs are not rendered by default.

0 comments on commit c414224

Please sign in to comment.