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