Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(roofs): Ensure grid snapping roofs moves points along XY ridge lines #708

Merged
merged 3 commits into from
Oct 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions dragonfly/building.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,22 @@ def all_room_2ds(self):
rooms.extend(story.room_2ds)
return rooms

def room_2ds_by_display_name(self, room_name):
"""Get all of the Room2Ds with a given display_name in the Building."""
rooms = []
for room in self.unique_room_2ds:
if room.display_name == room_name:
rooms.append(room)
return rooms

def room_3ds_by_display_name(self, room_name):
"""Get all of the 3D Rooms with a given display_name in the Building."""
rooms = []
for room in self.room_3ds:
if room.display_name == room_name:
rooms.append(room)
return rooms

def room_3ds_by_story(self, story_name):
"""Get all of the 3D Honeybee Room objects assigned to a particular story.

Expand Down Expand Up @@ -1360,6 +1376,55 @@ def separate_mid_floors(self, tolerance=0.01):
# assign the is_ground_contact and is_top_exposed properties
self._unique_stories[0].set_ground_contact()

def split_room_2d_vertically(self, room_id, tolerance=0.01):
"""Split a Room2D in this Building vertically if it crosses multiple stories.

If the selected Room2D does not extend past the midpoint of any Stories
in the Building, it will be left as it is.

Args:
room_id: The identifier of a Room2D within this Building which will
be split vertically with the Stories above it.
tolerance: The tolerance to be used for determining whether the Room2D
should be split. Default: 0.01, suitable for objects in meters.
"""
# loop through the stories of the model and find the Room2D
found_room, split_heights, split_stories = None, [], []
for story in self._unique_stories:
if found_room is not None:
flr_hgt = story.median_room2d_floor_height
if found_room.ceiling_height - tolerance > flr_hgt:
split_heights.append(flr_hgt)
split_stories.append(story)
else:
for rm in story.room_2ds:
if rm.identifier == room_id:
found_room = rm
break
# check if the room was found and whether it should be split
if found_room is None:
msg = 'No Room2D with the identifier "{}" was found in the ' \
'Building.'.format(room_id)
raise ValueError(msg)
if len(split_heights) == 0:
return # no splitting to be done
# split the room across the stories
for i, (split_hgt, add_story) in enumerate(zip(split_heights, split_stories)):
new_room = found_room.duplicate()
new_room.identifier = '{}_split{}'.format(new_room.identifier, i)
move_vec = Vector3D(0, 0, split_hgt - found_room.floor_height)
new_room.move(move_vec) # move the room to the correct floor height
try:
new_ceil_hgt = split_heights[i + 1]
except IndexError: # last story of the split list
new_ceil_hgt = found_room.ceiling_height
new_room.floor_to_ceiling_height = new_ceil_hgt - new_room.floor_height
new_room.is_ground_contact = False
add_story.add_room_2d(new_room)
# change the height of the original Room2D so that it doesn't overlap new rooms
found_room.floor_to_ceiling_height = split_heights[0] - found_room.floor_height
found_room.is_top_exposed = False

def set_outdoor_window_parameters(self, window_parameter):
"""Set all of the outdoor walls to have the same window parameters."""
for story in self._unique_stories:
Expand Down
17 changes: 16 additions & 1 deletion dragonfly/roof.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class RoofSpecification(object):
"""
__slots__ = ('_geometry', '_parent', '_is_resolved',
'_ridge_line_info', '_ridge_line_tolerance')
_ANG_TOL = 0.0174533 # angle tolerance in radians for determining X or Y alignment

def __init__(self, geometry):
"""Initialize RoofSpecification."""
Expand Down Expand Up @@ -492,7 +493,9 @@ def snap_to_grid(self, grid_increment, selected_indices=None, tolerance=0.01):
Note that the planes of the input roof Face3Ds will be preserved. This way,
the internal structure of the roof geometry will be conserved but the roof
will be extended to cover Room2Ds that might have otherwise been snapped to
the a node where they have no Roof geometry above them.
the a node where they have no Roof geometry above them. This command will
preserve all roof ridge lines and vertices along them will only be moved
if the ridge line is oriented to the X or Y axis.

Args:
grid_increment: A positive number for dimension of each grid cell. This
Expand Down Expand Up @@ -520,6 +523,13 @@ def snap_to_grid(self, grid_increment, selected_indices=None, tolerance=0.01):
new_x = grid_increment * round(pt.x / grid_increment)
new_y = grid_increment * round(pt.y / grid_increment)
new_poly.append(Point2D(new_x, new_y))
elif len(pt_info) == 1 and self._is_vector_xy(pt_info[0].v):
unit_vec = pt_info[0].v.normalize()
new_x = grid_increment * round(pt.x / grid_increment) \
if unit_vec.y < self._ANG_TOL else pt.x
new_y = grid_increment * round(pt.y / grid_increment) \
if unit_vec.x < self._ANG_TOL else pt.y
new_poly.append(Point2D(new_x, new_y))
else: # on a ridge line; don't move that point!
new_poly.append(pt)
new_polygons.append(new_poly)
Expand Down Expand Up @@ -892,6 +902,11 @@ def duplicate(self):
"""Get a copy of this object."""
return self.__copy__()

def _is_vector_xy(self, vector2d):
"""Check if a vector lies along the X or Y axis."""
unit_vec = vector2d.normalize()
return unit_vec.x < self._ANG_TOL or unit_vec.y < self._ANG_TOL

def _compute_ridge_line_info(self, tolerance):
"""Get a matrix of values for the ridge lines associated with each vertex.

Expand Down
49 changes: 49 additions & 0 deletions dragonfly/room2d.py
Original file line number Diff line number Diff line change
Expand Up @@ -2483,6 +2483,55 @@ def split_with_lines(self, lines, tolerance=0.01):
# create the final Room2Ds
return self._create_split_rooms(new_geos, tolerance)

def split_through_self_intersection(self, overlap_room=None, tolerance=0.01):
"""Get a list of non-intersecting Room2Ds if this Room2D intersects itself.

If the Room2D does not intersect itself, a list with only the current
Room2D instance will be returned.

Args:
overlap_room: An optional Room2D, which will be used to ensure that the
output list includes only the split Room2D with the highest overlap
with this Room2D. This is useful when this method is being used
as a cleanup operation for another method that accidentally created
a self-intersecting shape (eg. remove_short_segments). If None,
the output will include all Room2Ds resulting from the splitting
of this shape through self-intersection. (Default: None).
tolerance: The maximum difference between point values for them to be
considered distinct from one another. (Default: 0.01; suitable
for objects in Meters).

Returns:
A list of Room2D for the result of splitting this Room2D. Will be a
list with only the current Room2D instance if the Room2D does not
intersect itself
"""
# first, check that the floor geometry intersects itself
if not self.floor_geometry.boundary_polygon2d.is_self_intersecting:
return [self]
# split the room's boundary polygon through its self intersection
rm_poly = self.floor_geometry.boundary_polygon2d
split_polys = rm_poly.split_through_self_intersection(tolerance)
if overlap_room is not None:
poly_1 = overlap_room.floor_geometry.boundary_polygon2d
ov_areas = []
for poly_2 in split_polys:
new_geos = poly_1.boolean_intersect(poly_2, tolerance)
if new_geos is None or len(new_geos) == 0:
ov_areas.append(0) # the Face3Ds did not overlap with one another
ov_areas.append(sum(f.area for f in new_geos))
sort_polys = [p for _, p in sorted(zip(ov_areas, split_polys),
key=lambda pair: pair[0])]
split_polys = [sort_polys[-1]]
# create Face3Ds from the split polygons
new_geos = []
z_val, flr_plane = self.floor_height, self.floor_geometry.plane
for poly in split_polys:
face = Face3D([Point3D(pt.x, pt.y, z_val) for pt in poly], plane=flr_plane)
new_geos.append(face)
# create the final Room2Ds
return self._create_split_rooms(new_geos, tolerance)

def _create_split_rooms(self, face_3ds, tolerance):
"""Create Room2Ds from Face3Ds that were split from this Room2D."""
# create the Room2Ds
Expand Down
9 changes: 9 additions & 0 deletions dragonfly/story.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class Story(_BaseGeometry):
* is_above_ground
* min
* max
* median_room2d_floor_height
* user_data
"""
__slots__ = ('_room_2ds', '_floor_to_floor_height', '_floor_height',
Expand Down Expand Up @@ -454,6 +455,14 @@ def max(self):
"""
return self._calculate_max(self._room_2ds)

@property
def median_room2d_floor_height(self):
"""Get the median floor height of the Room2Ds of this Story."""
median_i = int(len(self._room_2ds) / 2)
flr_hgt = [room.floor_height for room in self._room_2ds]
flr_hgt.sort()
return flr_hgt[median_i]

def floor_geometry(self, tolerance=0.01):
"""Get a ladybug_geometry Polyface3D object representing the floor plate.

Expand Down
1 change: 1 addition & 0 deletions tests/json/room_for_remove_short_segs.dfjson

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions tests/model_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,28 @@ def test_skylight_merge_to_bounding_rectangle():
assert len(new_room.skylight_parameters) == 3


def test_room2d_split_through_self_intersection():
"""Test the splitting of a Room2D through self intersection."""
model_file = './tests/json/room_for_remove_short_segs.dfjson'
model = Model.from_file(model_file)
room = model.room_2ds[0]

assert room.check_self_intersecting(0.003, False, False) == ''
int_room = room.remove_short_segments(1.96)
assert int_room.check_self_intersecting(0.003, False, False) != ''

clean_rooms = int_room.split_through_self_intersection(room)
assert len(clean_rooms) == 1
assert clean_rooms[0].check_self_intersecting(0.003, False, False) == ''
assert clean_rooms[0].floor_area < room.floor_area

clean_rooms = int_room.split_through_self_intersection()
assert len(clean_rooms) == 2
for clr_rm in clean_rooms:
assert clr_rm.check_self_intersecting(0.003, False, False) == ''
assert clr_rm.floor_area < room.floor_area


def test_snap_to_grid():
"""Test the snap_to_grid method on Room2Ds."""
model_file = './tests/json/Level03.dfjson'
Expand Down
Loading