def associate_corresponding_boundary(boundary, doc): """Associate corresponding boundaries according to IFC definition. Reference to the other space boundary of the pair of two space boundaries on either side of a space separating thermal boundary element. https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/link/ifcrelspaceboundary2ndlevel.htm """ if (boundary.InternalOrExternalBoundary != "INTERNAL" or boundary.CorrespondingBoundary): return candidates = cleaned_corresponding_candidates(boundary) corresponding_boundary = get_best_corresponding_candidate( boundary, candidates) if corresponding_boundary: boundary.CorrespondingBoundary = corresponding_boundary corresponding_boundary.CorrespondingBoundary = boundary elif boundary.PhysicalOrVirtualBoundary == "VIRTUAL" and seems_too_smal( boundary): logger.warning(f""" Boundary {boundary.Label} from space {boundary.RelatingSpace.Id} has been removed. It is VIRTUAL, INTERNAL, thin and has no corresponding boundary. It looks like a parasite.""" ) doc.removeObject(boundary.Name) else: # Considering test above. Assume that it has been missclassified but log the issue. boundary.InternalOrExternalBoundary = "EXTERNAL" logger.warning(f""" No corresponding boundary found for {boundary.Label} from space {boundary.RelatingSpace.Id}. Assigning to EXTERNAL assuming it was missclassified as INTERNAL""")
def get_medial_axis(boundary1, boundary2, ei1, ei2) -> Optional[Part.Line]: line1 = utils.line_from_edge(utils.get_outer_wire(boundary1).Edges[ei1]) try: line2 = utils.line_from_edge( utils.get_outer_wire(boundary2).Edges[ei2]) except IndexError: logger.warning( f"""Cannot find closest edge index <{ei2}> in boundary <{boundary2.Label}> to rejoin boundary <{boundary1.Label}>""") return None # Case 2a : edges are not parallel if abs(line1.Direction.dot(line2.Direction)) < 1 - TOLERANCE: b1_plane = utils.get_plane(boundary1) line_intersect = line1.intersect2d(line2, b1_plane) if line_intersect: point1 = b1_plane.value(*line_intersect[0]) if line1.Direction.dot(line2.Direction) > 0: point2 = point1 + line1.Direction + line2.Direction else: point2 = point1 + line1.Direction - line2.Direction # Case 2b : edges are parallel else: point1 = (line1.Location + line2.Location) * 0.5 point2 = point1 + line1.Direction try: return Part.Line(point1, point2) except Part.OCCError: logger.exception( f"Failure in boundary id <{boundary1.SourceBoundary.Id}> {point1} and {point2} are equal" ) return None
def define_leso_type(boundary): try: ifc_type = boundary.RelatedBuildingElement.IfcType except AttributeError: if boundary.PhysicalOrVirtualBoundary != "VIRTUAL": logger.warning( f"Unable to define LesoType for boundary <{boundary.Id}>") return "Unknown" if ifc_type.startswith("IfcWindow"): return "Window" elif ifc_type.startswith("IfcDoor"): return "Door" elif ifc_type.startswith("IfcWall"): return "Wall" elif ifc_type.startswith("IfcSlab") or ifc_type == "IfcRoof": # Pointing up => Ceiling. Pointing down => Flooring if boundary.Normal.z > 0: return "Ceiling" return "Flooring" elif ifc_type.startswith("IfcOpeningElement"): return "Opening" else: logger.warning( f"Unable to define LesoType for Boundary Id <{boundary.Id}>") return "Unknown"
def merge_coplanar_boundaries(boundaries: list, doc=FreeCAD.ActiveDocument): """Try to merge coplanar boundaries""" if len(boundaries) == 1: return boundary1 = max(boundaries, key=lambda x: x.Area) # Ensure all boundaries are coplanar plane = utils.get_plane(boundary1) for boundary in boundaries: utils.project_boundary_onto_plane(boundary, plane) boundaries.remove(boundary1) remove_from_doc = list() # Attempt to merge boundaries while True and boundaries: for boundary2 in boundaries: if merge_boundaries(boundary1, boundary2): merge_corresponding_boundaries(boundary1, boundary2) boundaries.remove(boundary2) remove_from_doc.append(boundary2) break else: logger.warning( f"""Unable to merge boundaries RelSpaceBoundary Id <{boundary1.Id}> with boundaries <{", ".join(str(b.Id) for b in boundaries)}>""" ) break # Clean FreeCAD document if join operation was a success for fc_object in remove_from_doc: doc.removeObject(fc_object.Name)
def group_by_shared_element(boundaries) -> Dict[str, List["boundary"]]: elements_dict = dict() for rel_boundary in boundaries: try: key = f"{rel_boundary.RelatedBuildingElement.Id}_{rel_boundary.InternalOrExternalBoundary}" except AttributeError: if rel_boundary.PhysicalOrVirtualBoundary == "VIRTUAL": logger.info("IfcElement %s is VIRTUAL. Modeling error ?") key = "VIRTUAL" else: logger.warning("IfcElement %s has no RelatedBuildingElement", rel_boundary.Id) corresponding_boundary = rel_boundary.CorrespondingBoundary if corresponding_boundary: key += str(corresponding_boundary.Id) elements_dict.setdefault(key, []).append(rel_boundary) return elements_dict
def find_host(boundary): fallback_solution = None for boundary2 in valid_hosts(boundary): fallback_solution = boundary2 for inner_wire in utils.get_inner_wires(boundary2): if (not abs( Part.Face(inner_wire).Area - boundary.Area.Value) < TOLERANCE): continue return boundary2 if not fallback_solution: raise HostNotFound( f"No host found for RelSpaceBoundary Id<{boundary.Id}>") logger.warning( f"Using fallback solution to resolve host of RelSpaceBoundary Id<{boundary.Id}>" ) return fallback_solution
def merge_over_splitted_boundaries(space, doc=FreeCAD.ActiveDocument): """Try to merge oversplitted boundaries to reduce the number of boundaries and make sure that windows are not splitted as it is often with some authoring softwares like Revit. Why ? Less boundaries is more manageable, closer to what user expect and require less computational power""" boundaries = space.SecondLevel.Group # Considered as the minimal size for an oversplit to occur (1 ceiling, 3 wall, 1 flooring) if len(boundaries) <= 5: return elements_dict = group_by_shared_element(boundaries) # Merge hosted elements first for key, boundary_list in elements_dict.items(): if boundary_list[0].IsHosted and len(boundary_list) != 1: coplanar_groups = group_coplanar_boundaries(boundary_list) for group in coplanar_groups: merge_coplanar_boundaries(group, doc) for key, boundary_list in elements_dict.items(): # None coplanar boundaries should not be connected. # eg. round wall splitted with multiple orientations. # Case1: No oversplitted boundaries try: if boundary_list[0].IsHosted or len(boundary_list) == 1: continue except ReferenceError: continue coplanar_groups = group_coplanar_boundaries(boundary_list) for group in coplanar_groups: # Case 1 : only 1 boundary related to the same element. Cannot group boundaries. if len(group) == 1: continue # Case 2 : more than 1 boundary related to the same element might be grouped. try: merge_coplanar_boundaries(group, doc) except Part.OCCError: logger.warning( f"Cannot join boundaries in space <{space.Id}> with key <{key}>" )
def generate_space(self, ifc_space, parent): """Generate Space and RelSpaceBoundaries as defined in ifc_file. No post process.""" fc_space = Space.create_from_ifc(ifc_space, self) parent.addObject(fc_space) boundaries = fc_space.newObject("App::DocumentObjectGroup", "Boundaries") fc_space.Boundaries = boundaries second_levels = boundaries.newObject("App::DocumentObjectGroup", "SecondLevel") fc_space.SecondLevel = second_levels # All boundaries have their placement relative to space placement space_placement = self.get_placement(ifc_space) for ifc_boundary in (b for b in ifc_space.BoundedBy if is_second_level(b)): if not ifc_boundary.ConnectionGeometry: logger.warning( f"Boundary <{ifc_boundary.id()}> has no ConnectionGeometry and has therefore been ignored" ) continue try: fc_boundary = RelSpaceBoundary.create_from_ifc( ifc_entity=ifc_boundary, ifc_importer=self) fc_boundary.RelatingSpace = fc_space second_levels.addObject(fc_boundary) fc_boundary.Placement = space_placement except utils.ShapeCreationError: logger.warning( f"Failed to create fc_shape for RelSpaceBoundary <{ifc_boundary.id()}> even with fallback methode _part_by_mesh. IfcOpenShell bug ?" ) except utils.IsTooSmall: logger.warning( f"Boundary <{ifc_boundary.id()}> shape is too small and has been ignored" )
def guess_thickness(self, obj, ifc_entity): if obj.Material: thickness = getattr(obj.Material, "TotalThickness", 0) if thickness: return thickness if ifc_entity.is_a("IfcWall"): qto_lookup_name = "Qto_WallBaseQuantities" elif ifc_entity.is_a("IfcSlab"): qto_lookup_name = "Qto_SlabBaseQuantities" else: qto_lookup_name = "" if qto_lookup_name: for definition in ifc_entity.IsDefinedBy: if not definition.is_a("IfcRelDefinesByProperties"): continue if definition.RelatingPropertyDefinition.Name == qto_lookup_name: for quantity in definition.RelatingPropertyDefinition.Quantities: if quantity.Name == "Width": return quantity.LengthValue * self.fc_scale * self.ifc_scale if not getattr(ifc_entity, "Representation", None): return 0 if ifc_entity.IsDecomposedBy: thicknesses = [] for aggregate in ifc_entity.IsDecomposedBy: thickness = 0 for related in aggregate.RelatedObjects: thickness += self.guess_thickness(obj, related) thicknesses.append(thickness) return max(thicknesses) for representation in ifc_entity.Representation.Representations: if (representation.RepresentationIdentifier == "Box" and representation.RepresentationType == "BoundingBox"): if self.is_wall_like(obj.IfcType): return representation.Items[ 0].YDim * self.fc_scale * self.ifc_scale elif self.is_slab_like(obj.IfcType): return representation.Items[ 0].ZDim * self.fc_scale * self.ifc_scale else: return 0 try: fc_shape = self.element_local_shape_by_brep(ifc_entity) bbox = fc_shape.BoundBox except RuntimeError: return 0 # Returning bbox thickness for windows or doors is not insteresting # as it does not return frame thickness. if self.is_wall_like(obj.IfcType): return min(bbox.YLength, bbox.XLength) elif self.is_slab_like(obj.IfcType): return bbox.ZLength # Here we consider that thickness is distance between the 2 faces with higher area elif ifc_entity.is_a("IfcRoof"): faces = sorted(fc_shape.Faces, key=lambda x: x.Area) if len(faces) < 2: logger.warning( f"""{ifc_entity.is_a()}<{ifc_entity.id()}> has an invalid geometry (empty or less than 2 faces)""" ) return 0 return faces[-1].distToShape(faces[-2])[0] return 0
def rejoin_boundaries(space, sia_type): """ Rejoin boundaries after their translation to get a correct close shell surfaces. 1 Fill gaps between boundaries (2b) 2 Fill gaps gerenate by translation to make a boundary on the inside or outside boundary of building elements https://standards.buildingsmart.org/IFC/RELEASE/IFC4/ADD2_TC1/HTML/schema/ifcproductextension/lexical/ifcrelspaceboundary2ndlevel.htm # pylint: disable=line-too-long """ base_boundaries = space.SecondLevel.Group for base_boundary in base_boundaries: boundary1 = getattr(base_boundary, sia_type) if not boundary1: continue lines = [] fallback_lines = [ utils.line_from_edge(edge) for edge in utils.get_outer_wire(boundary1).Edges ] # bound_box used to make sure line solution is in a reallistic scope (distance <= 5 m) bound_box = boundary1.Shape.BoundBox bound_box.enlarge(5000) if (base_boundary.IsHosted or base_boundary.PhysicalOrVirtualBoundary == "VIRTUAL" or not base_boundary.RelatedBuildingElement): continue b1_plane = utils.get_plane(boundary1) for b2_id, (ei1, ei2), fallback_line in zip( base_boundary.ClosestBoundaries, enumerate(base_boundary.ClosestEdges), fallback_lines, ): base_boundary2 = utils.get_in_list_by_id(base_boundaries, b2_id) boundary2 = getattr(base_boundary2, sia_type, None) if not boundary2: logger.warning( f"Cannot find corresponding boundary with id <{b2_id}>") lines.append(fallback_line) continue # Case 1 : boundaries are not parallel line = get_intersecting_line(boundary1, boundary2) if line: if not is_valid_join(line, fallback_line): line = fallback_line if not bound_box.intersect(line.Location, line.Direction): line = fallback_line lines.append(line) continue # Case 2 : boundaries are parallel line = get_medial_axis(boundary1, boundary2, ei1, ei2) if line and is_valid_join(line, fallback_line): lines.append(line) continue lines.append(fallback_line) # Generate new shape try: outer_wire = utils.polygon_from_lines(lines, b1_plane) except (Part.OCCError, utils.ShapeCreationError): logger.exception( f"Invalid geometry while rejoining boundary Id <{base_boundary.Id}>" ) continue try: Part.Face(outer_wire) except Part.OCCError: logger.exception( f"Unable to rejoin boundary Id <{base_boundary.Id}>") continue inner_wires = utils.get_inner_wires(boundary1) try: utils.generate_boundary_compound(boundary1, outer_wire, inner_wires) except RuntimeError as err: logger.exception(err) continue boundary1.Area = area = boundary1.Shape.Area for inner_boundary in base_boundary.InnerBoundaries: area = area + inner_boundary.Shape.Area boundary1.AreaWithHosted = area