Coverage for bim2sim/tasks/bps/sb_correction.py: 14%
251 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-12 17:09 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-12 17:09 +0000
1"""Geometric Correction of Space Boundaries.
3This module contains all functions for geometric preprocessing of the BIM2SIM
4Elements that are relevant for exporting EnergyPlus Input Files and other BPS
5applications. Geometric preprocessing mainly relies on shape
6manipulations with OpenCascade (OCC). This module is prerequisite for the
7BIM2SIM PluginEnergyPlus. This module must be executed before exporting the
8EnergyPlus Input file.
9"""
10import copy
11import logging
12from typing import Union
14from ifcopenshell import guid
15from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform, \
16 BRepBuilderAPI_Sewing
17from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape
18from OCC.Core.BRepGProp import brepgprop_VolumeProperties, \
19 brepgprop_SurfaceProperties
20from OCC.Core.Extrema import Extrema_ExtFlag_MIN
21from OCC.Core.GProp import GProp_GProps
22from OCC.Core.TopAbs import TopAbs_FACE
23from OCC.Core.TopExp import TopExp_Explorer
24from OCC.Core.TopoDS import topods_Face, TopoDS_Shape
25from OCC.Core.gp import gp_Pnt, gp_Trsf, gp_XYZ, gp_Vec
27from bim2sim.elements.bps_elements import ExternalSpatialElement, \
28 SpaceBoundary, \
29 SpaceBoundary2B, ExtSpatialSpaceBoundary
30from bim2sim.tasks.base import ITask
31from bim2sim.tasks.common.inner_loop_remover import convex_decomposition, \
32 is_convex_no_holes, is_convex_slow
33from bim2sim.utilities.common_functions import filter_elements, \
34 get_spaces_with_bounds
35from bim2sim.utilities.pyocc_tools import PyOCCTools
36from bim2sim.tasks.base import Playground
38logger = logging.getLogger(__name__)
41class CorrectSpaceBoundaries(ITask):
42 """Advanced geometric preprocessing for Space Boundaries.
44 This class includes all functions for advanced geometric preprocessing
45 required for high level space boundary handling, e.g., required by
46 EnergyPlus export. See detailed explanation in the run
47 function below.
48 """
49 reads = ('elements',)
51 def __init__(self, playground: Playground):
52 super().__init__(playground)
54 def run(self, elements: dict):
55 """Geometric preprocessing for BPS.
57 This module contains all functions for geometric preprocessing of the BIM2SIM
58 Elements that are relevant for exporting BPS Input Files within the
59 Plugins EnergyPlus, Comfort and TEASER. This geometric preprocessing mainly
60 relies on shape manipulations with OpenCascade (OCC).
61 This task starts with linking the space boundaries to the dictionary
62 of elements. Additionally, geometric preprocessing operations are
63 executed, like moving opening elements to their parent surfaces (
64 unless they are already coplanar), the surface orientation of space
65 boundaries are fixed, and non-convex boundaries are fixed.
67 Args:
68 elements (dict): dictionary in the format dict[guid: element],
69 Dictionary of elements generated in previous IFC-based setup and
70 enrichment tasks. In this task, the elements are enriched with
71 the geometric preprocessed space_boundary items.
72 space_boundaries (dict): dictionary in the format dict[guid:
73 SpaceBoundary], dictionary of IFC-based space boundary elements.
74 """
75 if not self.playground.sim_settings.correct_space_boundaries:
76 return
77 logger.info("Geometric correction of space boundaries started...")
78 # todo: refactor elements to initial_elements.
79 # todo: space_boundaries should be already included in elements
80 self.move_children_to_parents(elements)
81 self.fix_surface_orientation(elements)
82 self.split_non_convex_bounds(
83 elements, self.playground.sim_settings.split_bounds)
84 self.add_and_split_bounds_for_shadings(
85 elements, self.playground.sim_settings.add_shadings,
86 self.playground.sim_settings.split_shadings)
87 logger.info("Geometric correction of space boundaries finished!")
89 def add_and_split_bounds_for_shadings(self, elements: dict,
90 add_shadings: bool,
91 split_shadings: bool):
92 """Add and split shading boundaries.
94 Enrich elements by space boundaries related to an
95 ExternalSpatialElement if shadings are to be added in the energyplus
96 workflow.
98 Args:
99 elements: dict[guid: element]
100 add_shadings: True if shadings shall be added
101 split_shadings: True if shading boundaries should be split in
102 non-convex boundaries
103 """
104 if add_shadings:
105 spatials = []
106 ext_spatial_elems = filter_elements(elements,
107 ExternalSpatialElement)
108 for elem in ext_spatial_elems:
109 for sb in elem.space_boundaries:
110 spatials.append(sb)
111 if spatials and split_shadings:
112 self.split_non_convex_shadings(elements, spatials)
114 @staticmethod
115 def move_children_to_parents(elements: dict):
116 """Move child space boundaries to parent boundaries.
118 In some IFC, the opening boundaries of external wall
119 boundaries are not coplanar. This function moves external opening
120 boundaries to related parent boundary (e.g. wall).
122 Args:
123 elements: dict[guid: element]
124 """
125 logger.info("Move openings to base surface, if needed")
126 boundaries = filter_elements(elements, SpaceBoundary)
127 for bound in boundaries:
128 if bound.parent_bound:
129 opening_obj = bound
130 # only external openings need to be moved
131 # all other are properly placed within parent boundary
132 if opening_obj.is_external:
133 distance = BRepExtrema_DistShapeShape(
134 opening_obj.bound_shape,
135 opening_obj.parent_bound.bound_shape,
136 Extrema_ExtFlag_MIN).Value()
137 if distance < 0.001:
138 continue
139 prod_vec = []
140 for i in opening_obj.bound_normal.Coord():
141 prod_vec.append(distance * i)
143 # moves opening to parent boundary
144 trsf = gp_Trsf()
145 coord = gp_XYZ(*prod_vec)
146 vec = gp_Vec(coord)
147 trsf.SetTranslation(vec)
149 opening_obj.bound_shape_org = opening_obj.bound_shape
150 opening_obj.bound_shape = BRepBuilderAPI_Transform(
151 opening_obj.bound_shape, trsf).Shape()
153 # check if opening has been moved to boundary correctly
154 # and otherwise move again in reversed direction
155 new_distance = BRepExtrema_DistShapeShape(
156 opening_obj.bound_shape,
157 opening_obj.parent_bound.bound_shape,
158 Extrema_ExtFlag_MIN).Value()
159 if new_distance > 1e-3:
160 prod_vec = []
161 op_normal = opening_obj.bound_normal.Reversed()
162 for i in op_normal.Coord():
163 prod_vec.append(new_distance * i)
164 trsf = gp_Trsf()
165 coord = gp_XYZ(*prod_vec)
166 vec = gp_Vec(coord)
167 trsf.SetTranslation(vec)
168 opening_obj.bound_shape = BRepBuilderAPI_Transform(
169 opening_obj.bound_shape, trsf).Shape()
170 opening_obj.reset('bound_center')
172 @staticmethod
173 def fix_surface_orientation(elements: dict):
174 """Fix orientation of space boundaries.
176 Fix orientation of all surfaces but openings by sewing followed
177 by disaggregation. Fix orientation of openings afterwards according
178 to orientation of parent bounds.
180 Args:
181 elements: dict[guid: element]
182 """
183 logger.info("Fix surface orientation")
184 spaces = get_spaces_with_bounds(elements)
185 for space in spaces:
186 face_list = []
187 for bound in space.space_boundaries:
188 # get all bounds within a space except openings
189 if bound.parent_bound:
190 continue
191 # append all faces within the space to face_list
192 face = PyOCCTools.get_face_from_shape(bound.bound_shape)
193 face_list.append(face)
194 if not face_list:
195 continue
196 # if the space has generated 2B space boundaries, add them to
197 # face_list
198 if hasattr(space, 'space_boundaries_2B'):
199 for bound in space.space_boundaries_2B:
200 face = PyOCCTools.get_face_from_shape(bound.bound_shape)
201 face_list.append(face)
202 # sew all faces within the face_list together
203 sew = BRepBuilderAPI_Sewing(0.0001)
204 for fc in face_list:
205 sew.Add(fc)
206 sew.Perform()
207 sewed_shape = sew.SewedShape()
208 fixed_shape = sewed_shape
209 # check volume of the sewed shape. If negative, not all the
210 # surfaces have the same orientation
211 p = GProp_GProps()
212 brepgprop_VolumeProperties(fixed_shape, p)
213 if p.Mass() < 0:
214 # complements the surface orientation within the fixed shape
215 fixed_shape.Complement()
216 # disaggregate the fixed_shape to a list of fixed_faces
217 f_exp = TopExp_Explorer(fixed_shape, TopAbs_FACE)
218 fixed_faces = []
219 while f_exp.More():
220 fixed_faces.append(topods_Face(f_exp.Current()))
221 f_exp.Next()
222 for fc in fixed_faces:
223 # compute the surface normal for each face
224 face_normal = PyOCCTools.simple_face_normal(
225 fc, check_orientation=False)
226 # compute the center of mass for the current face
227 p = GProp_GProps()
228 brepgprop_SurfaceProperties(fc, p)
229 face_center = p.CentreOfMass().XYZ()
230 complemented = False
231 for bound in space.space_boundaries:
232 # find the original bound by evaluating the distance of
233 # the face centers. Continue if the distance is greater
234 # than the tolerance.
235 if (gp_Pnt(bound.bound_center).Distance(
236 gp_Pnt(face_center)) > 1e-3):
237 continue
238 # check if the surfaces have the same surface area
239 if (bound.bound_area.m - p.Mass()) ** 2 < 0.01:
240 # complement the surfaces if needed
241 if fc.Orientation() == 1:
242 bound.bound_shape.Complement()
243 complemented = True
244 elif face_normal.Dot(bound.bound_normal) < 0:
245 bound.bound_shape.Complement()
246 complemented = True
247 if not complemented:
248 continue
249 # complement openings if parent holds openings
250 if bound.opening_bounds:
251 op_bounds = bound.opening_bounds
252 for op in op_bounds:
253 op.bound_shape.Complement()
254 break
255 if not hasattr(space, 'space_boundaries_2B'):
256 continue
257 # if the current face is a generated 2b bound, just keep the
258 # current face and delete the bound normal property, so it is
259 # recomputed the next time it is accessed.
260 for bound in space.space_boundaries_2B:
261 if gp_Pnt(bound.bound_center).Distance(
262 gp_Pnt(face_center)) < 1e-6:
263 bound.bound_shape = fc
264 if hasattr(bound, 'bound_normal'):
265 bound.reset('bound_normal')
266 break
268 def split_non_convex_bounds(self, elements: dict, split_bounds: bool):
269 """Split non-convex space boundaries.
271 This function splits non-convex shapes of space boundaries into
272 convex shapes. Convex shapes may be required for shading calculations
273 in Energyplus.
275 Args:
276 elements: dict[guid: element]
277 split_bounds: True if non-convex space boundaries should be split up
278 into convex shapes.
279 """
280 if not split_bounds:
281 return
282 logger.info("Split non-convex surfaces")
283 # filter elements for type SpaceBoundary
284 bounds = filter_elements(elements, SpaceBoundary)
285 if not bounds:
286 # if no elements of type SpaceBoundary are found, this function
287 # is applied on SpaceBoundary2B
288 bounds = filter_elements(elements, SpaceBoundary2B)
289 # filter for boundaries, that are not opening boundaries
290 bounds_except_openings = [b for b in bounds if not b.parent_bound]
291 conv = [] # list of new convex shapes (for debugging)
292 non_conv = [] # list of old non-convex shapes (for debugging)
293 for bound in bounds_except_openings:
294 try:
295 # check if bound has already been processed
296 if hasattr(bound, 'convex_processed'):
297 continue
298 # check if bound is convex
299 if is_convex_no_holes(bound.bound_shape):
300 continue
301 # check all space boundaries that
302 # are not parent to an opening bound
303 if bound.opening_bounds:
304 if is_convex_slow(bound.bound_shape):
305 continue
306 # handle shapes that contain opening bounds
307 # the surface area of an opening should not be split up
308 # in the parent face, so for splitting up parent faces,
309 # the opening boundary must be considered as a non-split
310 # area
311 convex_shapes = convex_decomposition(bound.bound_shape,
312 [op.bound_shape for op
313 in
314 bound.opening_bounds])
315 else:
316 # if bound does not have openings, simply compute its
317 # convex decomposition and returns a list of convex_shapes
318 convex_shapes = convex_decomposition(bound.bound_shape)
319 non_conv.append(bound)
320 if hasattr(bound, 'bound_normal'):
321 bound.reset('bound_normal')
322 # create new space boundaries from list of convex shapes,
323 # for both the bound itself and its corresponding bound (if it
324 # has
325 # one)
326 new_space_boundaries = self.create_new_convex_bounds(
327 convex_shapes, bound, bound.related_bound)
328 bound.convex_processed = True
329 # process related bounds of the processed bounds. For heat
330 # transfer the corresponding boundaries need to have same
331 # surface area and same number of vertices, so corresponding
332 # boundaries must be split up the same way. The split up has
333 # been taking care of when creating new convex bounds,
334 # so they only need to be removed here.
335 if (bound.related_bound and
336 bound.related_bound.ifc.RelatingSpace.is_a('IfcSpace')) \
337 and not bound.ifc.Description == '2b':
338 non_conv.append(bound.related_bound)
339 # delete the related bound from elements
340 del elements[bound.related_bound.guid]
341 bounds_except_openings.remove(bound.related_bound)
342 bound.related_bound.convex_processed = True
343 # delete the current bound from elements
344 del elements[bound.guid]
345 # add all new created convex bounds to elements
346 for new_bound in new_space_boundaries:
347 elements[new_bound.guid] = new_bound
348 if bound in new_bound.bound_element.space_boundaries:
349 new_bound.bound_element.space_boundaries.remove(bound)
350 new_bound.bound_element.space_boundaries.append(new_bound)
351 if bound in new_bound.bound_thermal_zone.space_boundaries:
352 new_bound.bound_thermal_zone.space_boundaries.remove(bound)
353 new_bound.bound_thermal_zone.space_boundaries.append(new_bound)
354 conv.append(new_bound)
355 except Exception as ex:
356 logger.warning(f"Unexpected {ex}. Converting bound "
357 f"{bound.guid} to convex shape failed. "
358 f"{type(ex)}")
360 @staticmethod
361 def create_new_boundary(
362 bound: SpaceBoundary,
363 shape: TopoDS_Shape) -> SpaceBoundary:
364 """Create a copy of a SpaceBoundary instance.
366 This function creates a new space boundary based on the existing one.
368 Args:
369 bound: SpaceBoundary
370 shape: Shape for new boundary
371 """
372 if isinstance(bound, SpaceBoundary2B):
373 new_bound = SpaceBoundary2B(elements={})
374 elif isinstance(bound, ExtSpatialSpaceBoundary):
375 new_bound = ExtSpatialSpaceBoundary(elements={})
376 else:
377 new_bound = SpaceBoundary(elements={})
378 new_bound.guid = guid.new()
379 new_bound.bound_element = bound.bound_element
380 new_bound.bound_thermal_zone = bound.bound_thermal_zone
381 new_bound.ifc = bound.ifc
382 new_bound.non_convex_guid = bound.non_convex_guid
383 new_bound.bound_shape = shape
384 new_bound.related_bound = bound.related_bound
385 new_bound.related_adb_bound = bound.related_adb_bound
386 new_bound.reset('is_external')
387 return new_bound
389 def create_new_convex_bounds(self, convex_shapes: list[TopoDS_Shape],
390 bound: Union[SpaceBoundary, SpaceBoundary2B],
391 related_bound: SpaceBoundary = None):
392 """Create new convex space boundaries.
394 This function creates new convex space boundaries from non-convex
395 space boundary shapes. As for heat transfer the corresponding boundaries
396 need to have same surface area and same number of vertices,
397 corresponding boundaries must be split up the same way. Thus,
398 the bound itself and the corresponding boundary (related_bound) are
399 treated equally here.
401 Args:
402 convex_shapes: List[convex TopoDS_Shape]
403 bound: either SpaceBoundary or SpaceBoundary2B
404 related_bound: None or SpaceBoundary (as SpaceBoundary2B do not
405 have a related_bound)
406 """
407 # keep the original guid as non_convex_guid
408 bound.non_convex_guid = bound.guid
409 new_space_boundaries = []
410 openings = []
411 if bound.opening_bounds:
412 openings.extend(bound.opening_bounds)
413 for shape in convex_shapes:
414 # loop through all new created convex shapes (which are subshapes
415 # of the original bound) and copy the original boundary to keep
416 # their properties. This new_bound has its own unique guid.
417 # bound_shape and bound_area are modified to the new_convex shape.
418 new_bound = self.create_new_boundary(bound, shape)
419 if openings:
420 new_bound.opening_bounds = []
421 for opening in openings:
422 # map the openings to the new parent surface
423 distance = BRepExtrema_DistShapeShape(
424 new_bound.bound_shape, opening.bound_shape,
425 Extrema_ExtFlag_MIN).Value()
426 if distance < 1e-3:
427 new_bound.opening_bounds.append(opening)
428 opening.parent_bound = new_bound
429 # check and fix surface normal if needed
430 if not all([abs(i) < 1e-3 for i in (
431 (new_bound.bound_normal - bound.bound_normal).Coord())]):
432 new_bound.bound_shape = PyOCCTools.flip_orientation_of_face(
433 new_bound.bound_shape)
434 new_bound.bound_normal = PyOCCTools.simple_face_normal(
435 new_bound.bound_shape)
436 # handle corresponding boundary (related_bound)
437 if (related_bound and bound.related_bound.ifc.RelatingSpace.is_a(
438 'IfcSpace')) and not bound.ifc.Description == '2b':
439 distance = BRepExtrema_DistShapeShape(
440 bound.bound_shape, related_bound.bound_shape,
441 Extrema_ExtFlag_MIN).Value()
442 related_bound.non_convex_guid = related_bound.guid
443 # move shape of the current bound to the position of the
444 # related bound if they have not been at the same position
445 # before.
446 if distance > 1e-3:
447 new_rel_shape = \
448 PyOCCTools.move_bound_in_direction_of_normal(
449 new_bound, distance, reverse=False)
450 else:
451 new_rel_shape = new_bound.bound_shape
452 # assign bound_shape to related_bound, flip surface
453 # orientation and recompute bound_normal and bound_area.
454 new_rel_bound = self.create_new_boundary(
455 related_bound, new_rel_shape)
456 new_rel_bound.bound_shape = PyOCCTools.flip_orientation_of_face(
457 new_rel_bound.bound_shape)
458 new_rel_bound.bound_normal = PyOCCTools.simple_face_normal(
459 new_rel_bound.bound_shape)
460 # new_rel_bound.reset('bound_area')
461 # new_rel_bound.bound_area = new_rel_bound.bound_area
462 # handle opening bounds of related bound
463 if new_bound.opening_bounds:
464 for op in new_bound.opening_bounds:
465 if not op.related_bound:
466 continue
467 new_rel_bound.opening_bounds.append(op.related_bound)
468 op.related_bound.parent_bound = new_rel_bound
469 new_bound.related_bound = new_rel_bound
470 new_rel_bound.related_bound = new_bound
471 new_space_boundaries.append(new_rel_bound)
472 new_space_boundaries.append(new_bound)
473 return new_space_boundaries
475 def split_non_convex_shadings(self, elements: dict,
476 spatial_bounds: list[SpaceBoundary]):
477 """Split non_convex shadings to convex shapes.
479 Args:
480 elements: dict[guid: element]
481 spatial_bounds: list of SpaceBoundary, that are connected to an
482 ExternalSpatialElement
483 """
484 # only considers the first spatial element for now. Extend this if
485 # needed.
486 spatial_elem = filter_elements(elements, ExternalSpatialElement)[0]
487 for spatial in spatial_bounds:
488 if is_convex_no_holes(spatial.bound_shape):
489 continue
490 try:
491 convex_shapes = convex_decomposition(spatial.bound_shape)
492 except Exception as ex:
493 logger.warning(f"Unexpected {ex}. Converting shading bound "
494 f"{spatial.guid} to convex shape failed. "
495 f"{type(ex)}")
496 new_space_boundaries = self.create_new_convex_bounds(convex_shapes,
497 spatial)
498 spatial_bounds.remove(spatial)
499 if spatial in spatial_elem.space_boundaries:
500 spatial_elem.space_boundaries.remove(spatial)
501 for new_bound in new_space_boundaries:
502 spatial_bounds.append(new_bound)
503 spatial_elem.space_boundaries.append(new_bound)