diff --git a/FreeCAD/BIMxBEM/freecad/bem/boundaries.py b/FreeCAD/BIMxBEM/freecad/bem/boundaries.py index be9aaed..b3286e1 100644 --- a/FreeCAD/BIMxBEM/freecad/bem/boundaries.py +++ b/FreeCAD/BIMxBEM/freecad/bem/boundaries.py @@ -1,1082 +1,1162 @@ # coding: utf8 """This module reads IfcRelSpaceBoundary from an IFC file and display them in FreeCAD""" import os import itertools import ifcopenshell import ifcopenshell.geom import FreeCAD import Part import FreeCADGui from bem_xml import BEMxml def ios_settings(brep): """Create ifcopenshell.geom.settings for various cases""" settings = ifcopenshell.geom.settings() settings.set(settings.EXCLUDE_SOLIDS_AND_SURFACES, False) settings.set(settings.INCLUDE_CURVES, True) if brep: settings.set(settings.USE_BREP_DATA, True) return settings BREP_SETTINGS = ios_settings(brep=True) MESH_SETTINGS = ios_settings(brep=False) +TOLERANCE = 0.001 """With IfcOpenShell 0.6.0a1 recreating face from wires seems to give more consistant results. Especially when inner boundaries touch outer boundary""" BREP = False # IfcOpenShell/IFC default unit is m, FreeCAD internal unit is mm SCALE = 1000 def generate_space(ifc_space, parent, doc=FreeCAD.ActiveDocument): fc_space = create_space_from_entity(ifc_space) 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 = get_placement(ifc_space) for ifc_boundary in (b for b in ifc_space.BoundedBy if b.Name == "2ndLevel"): face = make_relspaceboundary(ifc_boundary) second_levels.addObject(face) face.Placement = space_placement element = get_related_element(ifc_boundary, doc) if element: face.RelatedBuildingElement = element append(element, "ProvidesBoundaries", face) face.RelatingSpace = fc_space def generate_containers(ifc_parent, fc_parent, doc=FreeCAD.ActiveDocument): for rel_aggregates in ifc_parent.IsDecomposedBy: for element in rel_aggregates.RelatedObjects: if element.is_a("IfcSpace"): generate_space(element, fc_parent, doc) else: fc_container = create_container_from_entity(element) fc_parent.addObject(fc_container) generate_containers(element, fc_container, doc) def get_elements(doc=FreeCAD.ActiveDocument): """Generator throught FreeCAD document element of specific ifc_type""" for element in doc.Objects: try: if isinstance(element.Proxy, Element): yield element except AttributeError: continue def get_elements_by_ifctype(ifc_type: str, doc=FreeCAD.ActiveDocument): """Generator throught FreeCAD document element of specific ifc_type""" for element in doc.Objects: try: if element.IfcType == ifc_type: yield element except AttributeError: continue def generate_ifc_rel_space_boundaries(ifc_path, doc=FreeCAD.ActiveDocument): """Display IfcRelSpaceBoundaries from selected IFC file into FreeCAD documennt""" ifc_file = ifcopenshell.open(ifc_path) # Generate elements (Door, Window, Wall, Slab etc…) without their geometry elements_group = get_or_create_group("Elements", doc) ifc_elements = (e for e in ifc_file.by_type("IfcElement") if e.ProvidesBoundaries) for ifc_entity in ifc_elements: elements_group.addObject(create_fc_object_from_entity(ifc_entity)) # Generate projects structure and boundaries for ifc_project in ifc_file.by_type("IfcProject"): project = create_project_from_entity(ifc_project) generate_containers(ifc_project, project, doc) # Associate CorrespondingBoundary associate_corresponding_boundaries(doc) # Join over splitted boundaries for space in get_elements_by_ifctype("IfcSpace", doc): join_over_splitted_boundaries(space) # Associate Host / Hosted elements associate_host_element(ifc_file, elements_group) # Associate hosted elements an fill gaps for fc_space in get_elements_by_ifctype("IfcSpace", doc): fc_boundaries = fc_space.SecondLevel.Group # Minimal number of boundary is 5: 3 vertical faces, 2 horizontal faces # If there is less than 5 boundaries there is an issue or a new case to analyse if len(fc_boundaries) == 5: continue elif len(fc_boundaries) < 5: assert ValueError, f"{fc_space.Label} has less than 5 boundaries" # Find coplanar boundaries to identify hosted boundaries and fill gaps (2b) associate_coplanar_boundaries(fc_boundaries) # Associate hosted elements associate_inner_boundaries(fc_boundaries) def create_geo_boundaries(doc=FreeCAD.ActiveDocument): """Create SIA specific boundaries cf. https://www.sia.ch/fr/services/sia-norm/""" for space in get_elements_by_ifctype("IfcSpace", doc): close_space_boundaries(space) find_closest_edges(space) # create_geo_ext_boundaries(doc) # sia_interiors = boundaries.newObject("App::DocumentObjectGroup", "SIA_Interiors") # sia_exteriors = boundaries.newObject("App::DocumentObjectGroup", "SIA_Exteriors") # create_geo_int_boundaries(doc) doc.recompute() def write_xml(doc=FreeCAD.ActiveDocument): bem_xml = BEMxml() for project in get_elements_by_ifctype("IfcProject", doc): bem_xml.write_project(project) for space in get_elements_by_ifctype("IfcSpace", doc): bem_xml.write_space(space) for boundary in space.Group: bem_xml.write_boundary(boundary) for building_element in get_elements(): bem_xml.write_building_elements(building_element) return bem_xml def output_xml_to_path(bem_xml, xml_path=None): if not xml_path: xml_path = ( "./output.xml" if os.name == "nt" else "/home/cyril/git/BIMxBEM/output.xml" ) bem_xml.write_to_file(xml_path) def close_space_boundaries(space): sia = space.Boundaries.newObject("App::DocumentObjectGroup", "SIA") join_over_splitted_boundaries(space) boundaries = space.SecondLevel.Group for rel_boundary1 in boundaries: for edge1 in rel_boundary1.Shape.OuterWire.Edges: mid_point = edge1.CenterOfMass for rel_boundary2 in boundaries: if rel_boundary1 == rel_boundary2: continue for edge2 in enumerate(rel_boundary2.Shape.OuterWire.Edges): pass def join_over_splitted_boundaries(space): boundaries = space.SecondLevel.Group if len(boundaries) < 5: return elements_dict = dict() for rel_boundary in boundaries: key = f"{rel_boundary.RelatedBuildingElement.Id}" corresponding_boundary = rel_boundary.CorrespondingBoundary if corresponding_boundary: key += str(corresponding_boundary.Id) elements_dict.setdefault(key, []).append(rel_boundary) for boundary_list in elements_dict.values(): - # Case 1 : only 1 boundary related to the same element. Cannot group boundaries. - if len(boundary_list) == 1: - continue - # Case 2 : more than 1 boundary related to the same element might be grouped. - join_boundaries(boundary_list) + # None coplanar boundaries should not be connected. + # eg. round wall splitted with multiple orientations. + coplanar_boundaries = list() + for boundary in boundary_list: + for coplanar_list in coplanar_boundaries: + # TODO: Test if this test is not too strict considering precision + if is_coplanar(boundary, coplanar_list[0]): + coplanar_list.append(boundary) + else: + coplanar_boundaries.append([boundary]) + + for coplanar_list in coplanar_boundaries: + # Case 1 : only 1 boundary related to the same element. Cannot group boundaries. + if len(coplanar_list) == 1: + continue + # Case 2 : more than 1 boundary related to the same element might be grouped. + join_boundaries(coplanar_list) -def join_boundaries(boundaries: list): +def join_boundaries(boundaries: list, doc=FreeCAD.ActiveDocument): + """Try to join coplanar boundaries""" result_boundary = boundaries.pop() - result_shape = result_boundary.Shape + inner_wires = get_inner_wires(result_boundary)[:] + vectors1 = get_boundary_outer_vectors(result_boundary) while boundaries: - edges1 = result_shape.OuterWire.Edges for boundary2 in boundaries: - edges2 = boundary2.Shape.OuterWire.Edges - for (ei1, edge1), (ei2, edge2) in itertools.product( - enumerate(edges1), enumerate(edges2) + vectors2 = get_boundary_outer_vectors(boundary2) + for ei1, ei2 in itertools.product( + range(len(vectors1)), range(len(vectors2)) ): - if not is_collinear(edge1, edge2): + + # retrieves points from previous edge to next edge included. + p0_1, p0_2 = (vectors1[ei1], vectors1[(ei1 + 1) % len(vectors1)]) + p1_1, p1_2 = (vectors2[ei2], vectors2[(ei2 + 1) % len(vectors2)]) + + v0_12 = p0_2 - p0_1 + v1_12 = p1_2 - p1_1 + + dir0 = (v0_12).normalize() + dir1 = (v1_12).normalize() + + # if edge1 and edge2 are not collinear no junction is possible. + if not ( + (dir0.isEqual(dir1, TOLERANCE) or dir0.isEqual(-dir1, TOLERANCE)) + and v0_12.cross(p1_1 - p0_1) < TOLERANCE + ): continue - vectors1 = get_boundary_outer_vectors(result_boundary) - vectors2 = get_boundary_outer_vectors(boundary2) - v0_0, v0_1, v0_2, v0_3 = [ - vectors1[i % len(vectors1)] for i in range(ei1 - 1, ei1 + 3) - ] - v1_0, v1_1, v1_2, v1_3 = [ - vectors1[i % len(vectors2)] for i in range(ei2 - 1, ei2 + 3) - ] - tolerance = 1 # mm - if v0_1.isEqual(v1_1, tolerance): - pass - elif v0_1.isEqual(v1_2, tolerance): - pass + # Check in which order vectors1 and vectors2 should be connected + if dir0.isEqual(dir1, TOLERANCE): + p0_1_next_point = p1_2 + reverse_new_points = True + else: + p0_1_next_point = p1_1 + reverse_new_points = False + + # Check if edge1 and edge2 have a common segment + if not dir0.dot(p0_1_next_point - p0_1) < dir0.dot(p0_2 - p0_1): + continue + + # join vectors1 and vectors2 at indexes + new_points = vectors2[ei2 + 1 :] + vectors2[: ei2 + 1] + if reverse_new_points: + new_points.reverse() + + # Efficient way to insert elements at index : https://stackoverflow.com/questions/14895599/insert-an-element-at-specific-index-in-a-list-and-return-updated-list/48139870#48139870 + vectors1[ei1 + 1 : ei1 + 1] = new_points + + clean_vectors(vectors1) + inner_wires.extend(get_inner_wires(boundary2)) + boundaries.remove(boundary2) + doc.removeObject(boundary2.Name) + + # Replace existing shape with joined shapes + close_vectors(vectors1) + wires = [Part.makePolygon(vectors1)] + inner_wires + result_boundary.Shape = Part.makeFace(wires, "Part::FaceMakerBullseye") + + +def clean_vectors(vectors): + """Clean vectors for polygons creation + Keep only 1 point if 2 consecutive points are equal. + Remove point if it makes border go back and forth""" + count = len(vectors) + i = 0 + while count: + count -= 1 + p1 = vectors[i - 1] + p2 = vectors[i] + p3 = vectors[(i + 1) % len(vectors)] + if p2 == p3 or are_3points_collinear(p1, p2, p3): + vectors.remove(p2) + continue + i += 1 + + +def get_inner_wires(boundary): + return boundary.Shape.Wires[1:] + + +def close_vectors(vectors): + vectors.append(vectors[0]) + + +def vectors_dir(p1, p2) -> FreeCAD.Vector: + return (p2 - p1).normalize() + + +def are_3points_collinear(p1, p2, p3) -> bool: + dir1 = vectors_dir(p1, p2) + dir2 = vectors_dir(p2, p3) + return dir1.isEqual(dir2, TOLERANCE) or dir1.isEqual(-dir2, TOLERANCE) def get_boundary_outer_vectors(boundary): return [vx.Point for vx in boundary.Shape.OuterWire.Vertexes] def is_collinear(edge1, edge2): v0_0, v0_1 = (vx.Point for vx in edge1.Vertexes) v1_0, v1_1 = (vx.Point for vx in edge2.Vertexes) if is_collinear_or_parallel(v0_0, v0_1, v1_0, v1_1): return v0_0 == v1_0 or is_collinear_or_parallel(v0_0, v0_1, v0_0, v1_0) def is_collinear_or_parallel(v0_0, v0_1, v1_0, v1_1): return abs(direction(v0_0, v0_1).dot(direction(v1_0, v1_1))) > 0.9999 def direction(v0, v1): return (v0 - v1).normalize() def find_closest_edges(space): boundaries = [b for b in space.SecondLevel.Group if not b.IsHosted] closest_dict = dict() for i, boundary in enumerate(boundaries): n = len(boundary.Shape.OuterWire.Edges) closest_dict[i] = { "boundaries": [-1] * n, "edges": [-1] * n, "distances": [10000] * n, } for (bi1, boundary1), (bi2, boundary2) in itertools.combinations( enumerate(boundaries), 2 ): distances1 = closest_dict[bi1]["distances"] distances2 = closest_dict[bi2]["distances"] edges1 = boundary1.Shape.OuterWire.Edges edges2 = boundary2.Shape.OuterWire.Edges for (ei1, edge1), (ei2, edge2) in itertools.product( enumerate(edges1), enumerate(edges2) ): if not is_low_angle(edge1, edge2): continue distance = distances1[ei1] edge_to_edge = compute_distance(edge1, edge2) if edge_to_edge < distance: closest_dict[bi1]["boundaries"][ei1] = bi2 closest_dict[bi1]["edges"][ei1] = ei2 distances1[ei1] = round(edge_to_edge) distance = distances2[ei2] edge_to_edge = compute_distance(edge2, edge1) if edge_to_edge < distance: closest_dict[bi2]["boundaries"][ei2] = bi1 closest_dict[bi2]["edges"][ei2] = ei1 distances2[ei2] = round(edge_to_edge) for i, boundary in enumerate(boundaries): boundary.ClosestBoundaries = closest_dict[i]["boundaries"] boundary.ClosestEdges = closest_dict[i]["edges"] boundary.ClosestDistance = closest_dict[i]["distances"] def compute_distance(edge1, edge2): mid_point = edge1.CenterOfMass line_segment = (v.Point for v in edge2.Vertexes) return mid_point.distanceToLineSegment(*line_segment).Length def is_low_angle(edge1, edge2): dir1 = (edge1.Vertexes[1].Point - edge1.Vertexes[0].Point).normalize() dir2 = (edge2.Vertexes[1].Point - edge2.Vertexes[0].Point).normalize() return abs(dir1.dot(dir2)) > 0.5 # Low angle considered as < 30°. cos(pi/3)=0.5. def associate_host_element(ifc_file, elements_group): # Associate Host / Hosted elements ifc_elements = (e for e in ifc_file.by_type("IfcElement") if e.ProvidesBoundaries) for ifc_entity in ifc_elements: if ifc_entity.FillsVoids: host = get_element_by_guid(get_host_guid(ifc_entity), elements_group) hosted = get_element_by_guid(ifc_entity.GlobalId, elements_group) append(host, "HostedElements", hosted) hosted.HostElement = host def fill2b(fc_boundaries): """Modify boundary to include area of type 2b between boundaries""" for fc_boundary in fc_boundaries: non_hosted_shared_element = [ b for b in fc_boundary.ShareRelatedElementWith if not b.IsHosted ] # FIXME : Simplification which doesn't handle case where 2b touch a corner # FIXME : Currently assuming 2b boundaries are bounded by 4 vertices if not non_hosted_shared_element: continue elif len(non_hosted_shared_element) == 1: # Find side by side corresponding edges fc_boundary1 = fc_boundary fc_boundary2 = non_hosted_shared_element[0] edges1 = fc_boundary1.Shape.OuterWire.Edges edges2 = fc_boundary2.Shape.OuterWire.Edges min_distance = 100000 closest_indexes = None for (i, edge1), (j, edge2) in itertools.product( enumerate(edges1), enumerate(edges2) ): if edge1.Length <= edge2.Length: mid_point = edge1.CenterOfMass line_segment = (v.Point for v in edge2.Vertexes) else: mid_point = edge2.CenterOfMass line_segment = (v.Point for v in edge1.Vertexes) distance = mid_point.distanceToLineSegment(*line_segment).Length if distance < min_distance: min_distance = distance closest_indexes = (i, j) i, j = closest_indexes # Compute centerline vec1 = edges1[i].Vertexes[0].Point vec2 = edges2[j].Vertexes[0].Point line_segment = (v.Point for v in edges2[j].Vertexes) vec2 = vec2 + vec2.distanceToLineSegment(*line_segment) / 2 mid_line = Part.Line(vec1, vec2) # Compute intersection on center line between edges vxs1_len = len(fc_boundary1.Shape.Vertexes) vxs2_len = len(fc_boundary2.Shape.Vertexes) b1_v1 = mid_line.intersectCC(edges1[i - 1].Curve) b1_v2 = mid_line.intersectCC(edges1[(i + 1) % vxs1_len].Curve) b2_v1 = mid_line.intersectCC(edges2[j - 1].Curve) b2_v2 = mid_line.intersectCC(edges2[(j + 1) % vxs2_len].Curve) vectors = [v.Point for v in fc_boundary1.Shape.OuterWire.Vertexes] vectors[i] = b1_v1 vectors[(i + 1) % len(vectors)] = b1_v2 vectors.append(vectors[0]) wires = [Part.makePolygon(vectors)] wires.extend(fc_boundary1.Shape.Wires[1:]) new_shape = Part.Face(wires) fc_boundary1.Shape = new_shape def associate_inner_boundaries(fc_boundaries): """Associate hosted elements like a window or a door in a wall""" for fc_boundary in fc_boundaries: if not fc_boundary.IsHosted: continue candidates = set(fc_boundaries).intersection( fc_boundary.RelatedBuildingElement.HostElement.ProvidesBoundaries ) # If there is more than 1 candidate it doesn't really matter # as they share the same host element and space host_element = candidates.pop() fc_boundary.ParentBoundary = host_element append(host_element, "InnerBoundaries", fc_boundary) def associate_coplanar_boundaries(fc_boundaries): """ Find coplanar boundaries to identify hosted boundaries and fill gaps (2b) FIXME: Apparently in ArchiCAD, doors are not coplanar. Idea: Make it coplanar with other boundaries sharing the same space+wall before""" for fc_boundary_1, fc_boundary_2 in itertools.combinations(fc_boundaries, 2): if is_coplanar(fc_boundary_1, fc_boundary_2): append(fc_boundary_1, "ShareRelatedElementWith", fc_boundary_2) append(fc_boundary_2, "ShareRelatedElementWith", fc_boundary_1) def associate_corresponding_boundaries(doc=FreeCAD.ActiveDocument): # Associate CorrespondingBoundary for fc_boundary in get_elements_by_ifctype("IfcRelSpaceBoundary", doc): associate_corresponding_boundary(fc_boundary) def is_coplanar(shape_1, shape_2): """Intended for RelSpaceBoundary use only For some reason native Part.Shape.isCoplanar(Part.Shape) do not always work""" return get_plane(shape_1).toShape().isCoplanar(get_plane(shape_2).toShape()) def get_plane(fc_boundary): """Intended for RelSpaceBoundary use only""" return Part.Plane( fc_boundary.Shape.Vertexes[0].Point, fc_boundary.Shape.normalAt(0, 0) ) def append(doc_object, fc_property, value): """Intended to manipulate FreeCAD list like properties only""" current_value = getattr(doc_object, fc_property) current_value.append(value) setattr(doc_object, fc_property, current_value) def clean_corresponding_candidates(fc_boundary): if fc_boundary.PhysicalOrVirtualBoundary == "VIRTUAL": return [] other_boundaries = fc_boundary.RelatedBuildingElement.ProvidesBoundaries other_boundaries.remove(fc_boundary) return [ b for b in other_boundaries if not b.CorrespondingBoundary or b.RelatingSpace != fc_boundary.RelatingSpace ] def associate_corresponding_boundary(fc_boundary): """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 ( fc_boundary.InternalOrExternalBoundary != "INTERNAL" or fc_boundary.CorrespondingBoundary ): return other_boundaries = clean_corresponding_candidates(fc_boundary) if len(other_boundaries) == 1: corresponding_boundary = other_boundaries[0] else: center_of_mass = fc_boundary.Shape.CenterOfMass min_lenght = 10000 # No element has 10 m for boundary in other_boundaries: distance = center_of_mass.distanceToPoint(boundary.Shape.CenterOfMass) if distance < min_lenght: min_lenght = distance corresponding_boundary = boundary try: fc_boundary.CorrespondingBoundary = corresponding_boundary corresponding_boundary.CorrespondingBoundary = fc_boundary except NameError: # TODO: What to do with uncorrectly classified boundaries which have no corresponding boundary FreeCAD.Console.PrintLog( f"Boundary {fc_boundary.GlobalId} from space {fc_boundary}" ) return def get_related_element(ifc_entity, doc=FreeCAD.ActiveDocument): if not ifc_entity.RelatedBuildingElement: return guid = ifc_entity.RelatedBuildingElement.GlobalId for element in doc.Objects: try: if element.GlobalId == guid: return element except AttributeError: continue def get_host_guid(ifc_entity): return ( ifc_entity.FillsVoids[0] .RelatingOpeningElement.VoidsElements[0] .RelatingBuildingElement.GlobalId ) def get_element_by_guid(guid, elements_group): for fc_element in elements_group.Group: if fc_element.GlobalId == guid: return fc_element else: FreeCAD.Console.PrintLog(f"Unable to get element by {guid}") def get_thickness(ifc_entity): thickness = 0 if ifc_entity.IsDecomposedBy: ifc_entity = ifc_entity.IsDecomposedBy[0].RelatedObjects[0] for association in ifc_entity.HasAssociations: if not association.is_a("IfcRelAssociatesMaterial"): continue try: material_layers = association.RelatingMaterial.ForLayerSet.MaterialLayers except AttributeError: try: material_layers = association.RelatingMaterial.MaterialLayers except AttributeError: # TODO: Fallback method to handle materials with no layers. eg. association.RelatingMaterial.Materials continue for material_layer in material_layers: thickness += material_layer.LayerThickness * SCALE return thickness def create_geo_ext_boundaries(doc=FreeCAD.ActiveDocument): """Create boundaries necessary for SIA calculations""" project = next(get_elements_by_ifctype("IfcProject"), doc) is_from_revit = project.ApplicationIdentifier == "Revit" is_from_archicad = project.ApplicationFullName == "ARCHICAD-64" for boundary in get_elements_by_ifctype("IfcRelSpaceBoundary", doc): if boundary.IsHosted or boundary.PhysicalOrVirtualBoundary == "VIRTUAL": continue bem_boundary = make_bem_boundary(boundary, "geoExt") boundary.addObject(bem_boundary) thickness = boundary.RelatedBuildingElement.Thickness.Value ifc_type = boundary.RelatedBuildingElement.IfcType normal = boundary.Shape.normalAt(0, 0) if is_from_archicad: normal = -normal if boundary.InternalOrExternalBoundary != "INTERNAL": lenght = thickness if is_from_revit and ifc_type.startswith("IfcWall"): lenght /= 2 bem_boundary.Placement.move(normal * lenght) else: type1 = {"IfcSlab"} if ifc_type in type1: if normal.z > 0: lenght = thickness else: continue else: if is_from_revit: continue lenght = thickness / 2 bem_boundary.Placement.move(normal * lenght) def create_geo_int_boundaries(doc, group_2nd): """Create boundaries necessary for SIA calculations""" bem_group = doc.addObject("App::DocumentObjectGroup", "geoInt") is_from_revit = group_2nd.getParentGroup().ApplicationIdentifier == "Revit" is_from_archicad = group_2nd.getParentGroup().ApplicationFullName == "ARCHICAD-64" for fc_space in group_2nd.Group: for boundary in fc_space.Group: if boundary.IsHosted or boundary.PhysicalOrVirtualBoundary == "VIRTUAL": continue normal = boundary.Shape.normalAt(0, 0) if is_from_archicad: normal = -normal bem_boundary = make_bem_boundary(boundary, "geoInt") bem_group.addObject(bem_boundary) ifc_type = boundary.RelatedBuildingElement.IfcType if is_from_revit and ifc_type.startswith("IfcWall"): thickness = boundary.RelatedBuildingElement.Thickness.Value lenght = -thickness / 2 bem_boundary.Placement.move(normal * lenght) def create_fc_shape(space_boundary): """ Create Part shape from ifc geometry""" if BREP: try: return _part_by_brep( space_boundary.ConnectionGeometry.SurfaceOnRelatingElement ) except RuntimeError: print(f"Failed to generate brep from {space_boundary}") fallback = True if not BREP or fallback: try: return part_by_wires( space_boundary.ConnectionGeometry.SurfaceOnRelatingElement ) except RuntimeError: print(f"Failed to generate mesh from {space_boundary}") return _part_by_mesh( space_boundary.ConnectionGeometry.SurfaceOnRelatingElement ) def _part_by_brep(ifc_entity): """ Create a Part Shape from brep generated by ifcopenshell from ifc geometry""" ifc_shape = ifcopenshell.geom.create_shape(BREP_SETTINGS, ifc_entity) fc_shape = Part.Shape() fc_shape.importBrepFromString(ifc_shape.brep_data) fc_shape.scale(SCALE) return fc_shape def _part_by_mesh(ifc_entity): """ Create a Part Shape from mesh generated by ifcopenshell from ifc geometry""" return Part.Face(_polygon_by_mesh(ifc_entity)) def _polygon_by_mesh(ifc_entity): """Create a Polygon from a compatible ifc entity""" ifc_shape = ifcopenshell.geom.create_shape(MESH_SETTINGS, ifc_entity) ifc_verts = ifc_shape.verts fc_verts = [ FreeCAD.Vector(ifc_verts[i : i + 3]).scale(SCALE, SCALE, SCALE) for i in range(0, len(ifc_verts), 3) ] fc_verts = verts_clean(fc_verts) return Part.makePolygon(fc_verts) def verts_clean(vertices): """For some reason, vertices are not always clean and sometime a same vertex is repeated""" new_verts = list() for i in range(len(vertices) - 1): if vertices[i] != vertices[i + 1]: new_verts.append(vertices[i]) new_verts.append(vertices[-1]) return new_verts def part_by_wires(ifc_entity): """ Create a Part Shape from ifc geometry""" boundaries = list() boundaries.append(_polygon_by_mesh(ifc_entity.OuterBoundary)) try: inner_boundaries = ifc_entity.InnerBoundaries for inner_boundary in tuple(inner_boundaries) if inner_boundaries else tuple(): boundaries.append(_polygon_by_mesh(inner_boundary)) except RuntimeError: pass fc_shape = Part.makeFace(boundaries, "Part::FaceMakerBullseye") matrix = get_matrix(ifc_entity.BasisSurface.Position) fc_shape = fc_shape.transformGeometry(matrix) return fc_shape def get_matrix(position): """Transform position to FreeCAD.Matrix""" location = FreeCAD.Vector(position.Location.Coordinates).scale(SCALE, SCALE, SCALE) v_1 = FreeCAD.Vector(position.RefDirection.DirectionRatios) v_3 = FreeCAD.Vector(position.Axis.DirectionRatios) v_2 = v_3.cross(v_1) # fmt: off matrix = FreeCAD.Matrix( v_1.x, v_2.x, v_3.x, location.x, v_1.y, v_2.y, v_3.y, location.y, v_1.z, v_2.z, v_3.z, location.z, 0, 0, 0, 1, ) # fmt: on return matrix def get_placement(space): """Retrieve object placement""" space_geom = ifcopenshell.geom.create_shape(BREP_SETTINGS, space) # IfcOpenShell matrix values FreeCAD matrix values are transposed ios_matrix = space_geom.transformation.matrix.data m_l = list() for i in range(3): line = list(ios_matrix[i::3]) line[-1] *= SCALE m_l.extend(line) return FreeCAD.Matrix(*m_l) def get_color(ifc_boundary): """Return a color depending on IfcClass given""" product_colors = { "IfcWall": (0.7, 0.3, 0.0), "IfcWindow": (0.0, 0.7, 1.0), "IfcSlab": (0.7, 0.7, 0.5), "IfcRoof": (0.0, 0.3, 0.0), "IfcDoor": (1.0, 1.0, 1.0), } if ifc_boundary.PhysicalOrVirtualBoundary == "VIRTUAL": return (1.0, 0.0, 1.0) ifc_product = ifc_boundary.RelatedBuildingElement for product, color in product_colors.items(): # Not only test if IFC class is in dictionnary but it is a subclass if ifc_product.is_a(product): return color else: print(f"No color found for {ifc_product.is_a()}") return (0.0, 0.0, 0.0) def get_or_create_group(name, doc=FreeCAD.ActiveDocument): """Get group by name or create one if not found""" group = doc.findObjects("App::DocumentObjectGroup", name) if group: return group[0] return doc.addObject("App::DocumentObjectGroup", name) def make_relspaceboundary(ifc_entity): """Stantard FreeCAD FeaturePython Object creation method""" obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", "RelSpaceBoundary") # ViewProviderRelSpaceBoundary(obj.ViewObject) RelSpaceBoundary(obj, ifc_entity) try: obj.ViewObject.Proxy = 0 except AttributeError: FreeCAD.Console.PrintLog("No ViewObject ok if running with no Gui") return obj class Root: """Wrapping various IFC entity : https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/link/ifcroot.htm """ def __init__(self, obj, ifc_entity): self.Type = self.__class__.__name__ obj.Proxy = self obj.addExtension("App::GroupExtensionPython", self) ifc_attributes = "IFC Attributes" obj.addProperty("App::PropertyString", "IfcType", "IFC") obj.addProperty("App::PropertyInteger", "Id", ifc_attributes) obj.addProperty("App::PropertyString", "GlobalId", ifc_attributes) obj.addProperty("App::PropertyString", "Description", ifc_attributes) obj.Id = ifc_entity.id() obj.GlobalId = ifc_entity.GlobalId obj.IfcType = ifc_entity.is_a() self.set_label(obj, ifc_entity) try: obj.Description = ifc_entity.Description except TypeError: pass def onChanged(self, obj, prop): """Do something when a property has changed""" return def execute(self, obj): """Do something when doing a recomputation, this method is mandatory""" return @staticmethod def set_label(obj, ifc_entity): """Allow specific method for specific elements""" obj.Label = "{} {}".format(ifc_entity.id(), ifc_entity.Name) @classmethod def create(cls, obj_name, ifc_entity=None): """Stantard FreeCAD FeaturePython Object creation method ifc_entity : Optionnally provide a base entity. """ obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", obj_name) feature_python_object = cls(obj) return obj class ViewProviderRoot: def __init__(self, vobj): vobj.Proxy = self vobj.addExtension("Gui::ViewProviderGroupExtensionPython", self) class RelSpaceBoundary(Root): """Wrapping IFC entity :  https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/link/ifcrelspaceboundary2ndlevel.htm""" def __init__(self, obj, ifc_entity): super().__init__(obj, ifc_entity) obj.Proxy = self bem_category = "BEM" ifc_attributes = "IFC Attributes" obj.addProperty("App::PropertyLink", "RelatingSpace", ifc_attributes) obj.addProperty("App::PropertyLink", "RelatedBuildingElement", ifc_attributes) obj.addProperty( "App::PropertyEnumeration", "PhysicalOrVirtualBoundary", ifc_attributes ).PhysicalOrVirtualBoundary = ["PHYSICAL", "VIRTUAL", "NOTDEFINED"] obj.addProperty( "App::PropertyEnumeration", "InternalOrExternalBoundary", ifc_attributes ).InternalOrExternalBoundary = [ "INTERNAL", "EXTERNAL", "EXTERNAL_EARTH", "EXTERNAL_WATER", "EXTERNAL_FIRE", "NOTDEFINED", ] obj.addProperty("App::PropertyLink", "CorrespondingBoundary", ifc_attributes) obj.addProperty("App::PropertyLink", "ParentBoundary", ifc_attributes) obj.addProperty("App::PropertyLinkList", "InnerBoundaries", ifc_attributes) obj.addProperty("App::PropertyLink", "ProcessedShape", bem_category) obj.addProperty("App::PropertyIntegerList", "ClosestBoundaries", bem_category) obj.addProperty("App::PropertyIntegerList", "ClosestEdges", bem_category) obj.addProperty("App::PropertyIntegerList", "ClosestDistance", bem_category) obj.addProperty("App::PropertyBoolList", "ClosestHasSameNormal", bem_category) obj.addProperty( "App::PropertyLinkList", "ShareRelatedElementWith", bem_category ) obj.addProperty("App::PropertyBool", "IsHosted", bem_category) obj.addProperty("App::PropertyArea", "Area", bem_category) obj.addProperty("App::PropertyArea", "AreaWithHosted", bem_category) obj.addProperty("App::PropertyLink", "geoInt", bem_category) obj.addProperty("App::PropertyLink", "geoExt", bem_category) obj.ViewObject.ShapeColor = get_color(ifc_entity) obj.GlobalId = ifc_entity.GlobalId obj.InternalOrExternalBoundary = ifc_entity.InternalOrExternalBoundary obj.PhysicalOrVirtualBoundary = ifc_entity.PhysicalOrVirtualBoundary obj.Shape = create_fc_shape(ifc_entity) obj.Area = obj.AreaWithHosted = obj.Shape.Area self.set_label(obj, ifc_entity) if not obj.PhysicalOrVirtualBoundary == "VIRTUAL": obj.IsHosted = bool(ifc_entity.RelatedBuildingElement.FillsVoids) self.coplanar_with = [] def onChanged(self, obj, prop): super().onChanged(obj, prop) if prop == "InnerBoundaries": obj.AreaWithHosted = self.recompute_area_with_hosted(obj) @staticmethod def recompute_area_with_hosted(obj): """Recompute area including inner boundaries""" area = obj.Area for boundary in obj.InnerBoundaries: area = area + boundary.Area return area @staticmethod def set_label(obj, ifc_entity): try: obj.Label = "{} {}".format( ifc_entity.RelatedBuildingElement.id(), ifc_entity.RelatedBuildingElement.Name, ) except AttributeError: FreeCAD.Console.PrintLog( f"{ifc_entity.GlobalId} has no RelatedBuildingElement" ) return def create_fc_object_from_entity(ifc_entity): """Stantard FreeCAD FeaturePython Object creation method""" obj_name = "Element" obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", obj_name) Element(obj, ifc_entity) try: obj.ViewObject.Proxy = 0 except AttributeError: FreeCAD.Console.PrintLog("No ViewObject ok if running with no Gui") return obj class Element(Root): """Wrapping various IFC entity : https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/schema/ifcproductextension/lexical/ifcelement.htm """ def __init__(self, obj, ifc_entity): super().__init__(obj, ifc_entity) self.Type = "IfcRelSpaceBoundary" obj.Proxy = self ifc_attributes = "IFC Attributes" bem_category = "BEM" obj.addProperty("App::PropertyLinkList", "HasAssociations", ifc_attributes) obj.addProperty("App::PropertyLinkList", "FillsVoids", ifc_attributes) obj.addProperty("App::PropertyLinkList", "HasOpenings", ifc_attributes) obj.addProperty("App::PropertyLinkList", "ProvidesBoundaries", ifc_attributes) obj.addProperty("App::PropertyLinkList", "HostedElements", bem_category) obj.addProperty("App::PropertyLink", "HostElement", bem_category) obj.addProperty("App::PropertyLength", "Thickness", ifc_attributes) ifc_walled_entities = {"IfcWall", "IfcSlab", "IfcRoof"} for entity_class in ifc_walled_entities: if ifc_entity.is_a(entity_class): obj.Thickness = get_thickness(ifc_entity) def make_bem_boundary(boundary, geo_type): """Stantard FreeCAD FeaturePython Object creation method""" obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", "BEMBoundary") # ViewProviderRelSpaceBoundary(obj.ViewObject) BEMBoundary(obj, boundary) setattr(boundary, geo_type, obj) try: obj.ViewObject.Proxy = 0 obj.ViewObject.ShapeColor = boundary.ViewObject.ShapeColor except AttributeError: FreeCAD.Console.PrintLog("No ViewObject ok if running with no Gui") return obj class BEMBoundary: def __init__(self, obj, boundary): self.Type = "BEMBoundary" category_name = "BEM" obj.addProperty("App::PropertyLink", "SourceBoundary", category_name) obj.SourceBoundary = boundary obj.addProperty("App::PropertyArea", "Area", category_name) obj.addProperty("App::PropertyArea", "AreaWithHosted", category_name) obj.Shape = boundary.Shape.copy() obj.Area = obj.Shape.Area obj.AreaWithHosted = self.recompute_area_with_hosted(obj) self.set_label(obj) @staticmethod def recompute_area_with_hosted(obj): """Recompute area including inner boundaries""" area = obj.Area for boundary in obj.SourceBoundary.InnerBoundaries: area = area + boundary.Area return area @staticmethod def set_label(obj): obj.Label = obj.SourceBoundary.Label def create_container_from_entity(ifc_entity): """Stantard FreeCAD FeaturePython Object creation method""" obj_name = "Project" obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", obj_name) Container(obj, ifc_entity) if FreeCAD.GuiUp: obj.ViewObject.Proxy = ViewProviderRoot(obj.ViewObject) return obj class Container(Root): """Representation of an IfcProject: https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/link/ifcproject.htm""" def __init__(self, obj, ifc_entity): super().__init__(obj, ifc_entity) self.ifc_entity = ifc_entity self.setProperties(obj) def setProperties(self, obj): return def create_project_from_entity(ifc_entity): """Stantard FreeCAD FeaturePython Object creation method""" obj_name = "Project" obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", obj_name) Project(obj, ifc_entity) if FreeCAD.GuiUp: obj.ViewObject.Proxy = ViewProviderRoot(obj.ViewObject) return obj class Project(Root): """Representation of an IfcProject: https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/link/ifcproject.htm""" def __init__(self, obj, ifc_entity): super().__init__(obj, ifc_entity) self.ifc_entity = ifc_entity self.setProperties(obj) def setProperties(self, obj): ifc_entity = self.ifc_entity ifc_attributes = "IFC Attributes" obj.addProperty("App::PropertyString", "LongName", ifc_attributes) obj.addProperty("App::PropertyVector", "TrueNorth", ifc_attributes) obj.addProperty("App::PropertyVector", "WorldCoordinateSystem", ifc_attributes) obj.LongName = ifc_entity.LongName obj.TrueNorth = FreeCAD.Vector( *ifc_entity.RepresentationContexts[0].TrueNorth.DirectionRatios ) obj.WorldCoordinateSystem = FreeCAD.Vector( ifc_entity.RepresentationContexts[ 0 ].WorldCoordinateSystem.Location.Coordinates ) owning_application = "OwningApplication" obj.addProperty( "App::PropertyString", "ApplicationIdentifier", owning_application ) obj.addProperty("App::PropertyString", "ApplicationVersion", owning_application) obj.addProperty( "App::PropertyString", "ApplicationFullName", owning_application ) owning_application = ifc_entity.OwnerHistory.OwningApplication obj.ApplicationIdentifier = owning_application.ApplicationIdentifier obj.ApplicationVersion = owning_application.Version obj.ApplicationFullName = owning_application.ApplicationFullName def create_space_from_entity(ifc_entity): """Stantard FreeCAD FeaturePython Object creation method""" obj_name = "Space" obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", obj_name) Space(obj, ifc_entity) if FreeCAD.GuiUp: obj.ViewObject.Proxy = ViewProviderRoot(obj.ViewObject) return obj class Space(Root): """Representation of an IfcProject: https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/link/ifcproject.htm""" def __init__(self, obj, ifc_entity): super().__init__(obj, ifc_entity) self.ifc_entity = ifc_entity self.setProperties(obj) def setProperties(self, obj): ifc_entity = self.ifc_entity category_name = "Boundaries" obj.addProperty("App::PropertyLink", "Boundaries", category_name) obj.addProperty("App::PropertyLink", "SecondLevel", category_name) obj.addProperty("App::PropertyLink", "SIA", category_name) obj.addProperty("App::PropertyLink", "SIA_Interiors", category_name) obj.addProperty("App::PropertyLink", "SIA_Exteriors", category_name) space_full_name = f"{ifc_entity.Name} {ifc_entity.LongName}" obj.Label = space_full_name try: obj.Description = ifc_entity.Description except TypeError: pass return obj if __name__ == "__main__": if os.name == "nt": TEST_FOLDER = r"C:\git\BIMxBEM\IfcTestFiles" else: TEST_FOLDER = "/home/cyril/git/BIMxBEM/IfcTestFiles/" TEST_FILES = [ "Triangle_2x3_A22.ifc", "Triangle_2x3_R19.ifc", "2Storey_2x3_A22.ifc", "2Storey_2x3_R19.ifc", "0014_Vernier112D_ENE_ModèleÉnergétique_R20.ifc", "Investigation_test_R19.ifc", ] IFC_PATH = os.path.join(TEST_FOLDER, TEST_FILES[2]) DOC = FreeCAD.ActiveDocument if DOC: # Remote debugging import ptvsd # Allow other computers to attach to ptvsd at this IP address and port. ptvsd.enable_attach(address=("localhost", 5678), redirect_output=True) # Pause the program until a remote debugger is attached ptvsd.wait_for_attach() # breakpoint() display_boundaries(ifc_path=IFC_PATH, doc=DOC) FreeCADGui.activeView().viewIsometric() FreeCADGui.SendMsgToActiveView("ViewFit") else: FreeCADGui.showMainWindow() DOC = FreeCAD.newDocument() generate_ifc_rel_space_boundaries(IFC_PATH, DOC) create_geo_boundaries(DOC) FreeCADGui.activeView().viewIsometric() FreeCADGui.SendMsgToActiveView("ViewFit") FreeCADGui.exec_loop()