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

1"""Geometric Correction of Space Boundaries. 

2 

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 

13 

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 

25 

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 

36 

37logger = logging.getLogger(__name__) 

38 

39 

40class CorrectSpaceBoundaries(ITask): 

41 """Advanced geometric preprocessing for Space Boundaries. 

42 

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',) 

49 

50 def __init__(self, playground: Playground): 

51 super().__init__(playground) 

52 

53 def run(self, elements: dict): 

54 """Geometric preprocessing for BPS. 

55 

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. 

65 

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!") 

87 

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. 

92 

93 Enrich elements by space boundaries related to an 

94 ExternalSpatialElement if shadings are to be added in the energyplus 

95 workflow. 

96 

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) 

112 

113 @staticmethod 

114 def move_children_to_parents(elements: dict): 

115 """Move child space boundaries to parent boundaries. 

116 

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). 

120 

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) 

141 

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) 

147 

148 opening_obj.bound_shape_org = opening_obj.bound_shape 

149 opening_obj.bound_shape = BRepBuilderAPI_Transform( 

150 opening_obj.bound_shape, trsf).Shape() 

151 

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') 

170 

171 @staticmethod 

172 def fix_surface_orientation(elements: dict): 

173 """Fix orientation of space boundaries. 

174 

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. 

178 

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 

266 

267 def split_non_convex_bounds(self, elements: dict, split_bounds: bool): 

268 """Split non-convex space boundaries. 

269 

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. 

273 

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)}") 

358 

359 @staticmethod 

360 def create_new_boundary( 

361 bound: SpaceBoundary, 

362 shape: TopoDS_Shape) -> SpaceBoundary: 

363 """Create a copy of a SpaceBoundary instance. 

364 

365 This function creates a new space boundary based on the existing one. 

366 

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 

387 

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. 

392 

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. 

399 

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 

473 

474 def split_non_convex_shadings(self, elements: dict, 

475 spatial_bounds: list[SpaceBoundary]): 

476 """Split non_convex shadings to convex shapes. 

477 

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)