diff --git a/FreeCAD/BIMxBEM/LICENSE b/FreeCAD/BIMxBEM/LICENSE new file mode 100644 index 0000000..153d416 --- /dev/null +++ b/FreeCAD/BIMxBEM/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/FreeCAD/BIMxBEM/freecad/bem/bem_xml.py b/FreeCAD/BIMxBEM/freecad/bem/bem_xml.py index 5e7ecd8..a342270 100644 --- a/FreeCAD/BIMxBEM/freecad/bem/bem_xml.py +++ b/FreeCAD/BIMxBEM/freecad/bem/bem_xml.py @@ -1,166 +1,174 @@ # coding: utf8 -"""This module write boundaries informations to an xml format for BEM software use""" +"""This module write boundaries informations to an xml format for BEM software use + +© All rights reserved. +ECOLE POLYTECHNIQUE FEDERALE DE LAUSANNE, Switzerland, Laboratory CNPA, 2019-2020 + +See the LICENSE.TXT file for more details. + +Author : Cyril Waechter +""" import xml.etree.ElementTree as ET SCALE = 1000 class BEMxml: """Contains methods to write each kind of object to BEMxml""" def __init__(self): self.root = ET.Element("bimbem") self.tree = ET.ElementTree(self.root) self.projects = ET.SubElement(self.root, "Projects") self.spaces = ET.SubElement(self.root, "Spaces") self.boundaries = ET.SubElement(self.root, "Boundaries") self.building_elements = ET.SubElement(self.root, "BuildingElements") @staticmethod def write_root_attrib(xml_element, fc_object): ET.SubElement(xml_element, "Id").text = str(fc_object.Id) ET.SubElement(xml_element, "GlobalId").text = fc_object.GlobalId ET.SubElement(xml_element, "Name").text = fc_object.Name ET.SubElement(xml_element, "Description").text = fc_object.Description ET.SubElement(xml_element, "IfcType").text = fc_object.IfcType def write_project(self, fc_object): project = ET.SubElement(self.projects, "Project") self.write_root_attrib(project, fc_object) ET.SubElement(project, "LongName").text = fc_object.LongName north = ET.SubElement(project, "TrueNorth") ET.SubElement(north, "point", vector_to_dict(fc_object.TrueNorth)) wcs = ET.SubElement(project, "WorldCoordinateSystem") ET.SubElement(wcs, "point", vector_to_dict(fc_object.WorldCoordinateSystem)) self.sites = ET.SubElement(project, "Sites") for site in fc_object.Group: self.write_site(site) def write_site(self, fc_object): site = ET.SubElement(self.sites, "Site") self.write_root_attrib(site, fc_object) self.buildings = ET.SubElement(site, "Buildings") for building in fc_object.Group: self.write_building(building) def write_building(self, fc_object): site = ET.SubElement(self.buildings, "Building") self.write_root_attrib(site, fc_object) self.storeys = ET.SubElement(site, "Storeys") for storey in fc_object.Group: self.write_storey(storey) def write_storey(self, fc_object): storey = ET.SubElement(self.storeys, "Storey") self.write_root_attrib(storey, fc_object) spaces = ET.SubElement(storey, "Spaces") for space in fc_object.Group: ET.SubElement(spaces, "Space").text = str(space.Id) def write_space(self, fc_object): space = ET.SubElement(self.spaces, "Space") self.write_root_attrib(space, fc_object) boundaries = ET.SubElement(space, "Boundaries") for boundary in fc_object.SecondLevel.Group: ET.SubElement(boundaries, "Boundary").text = str(boundary.Id) def write_boundary(self, fc_object): boundary = ET.SubElement(self.boundaries, "Boundary") self.write_root_attrib(boundary, fc_object) id_references = ( "CorrespondingBoundary", "RelatedBuildingElement", ) for name in id_references: self.append_id_element(boundary, fc_object, name) text_references = ( "InternalOrExternalBoundary", "PhysicalOrVirtualBoundary", "LesoType" ) for name in text_references: self.append_text_element(boundary, fc_object, name) ET.SubElement(boundary, "ParentBoundary").text = ( str(fc_object.ParentBoundary) if fc_object.ParentBoundary else "" ) ET.SubElement(boundary, "RelatingSpace").text = ( str(fc_object.RelatingSpace) if fc_object.RelatingSpace else "" ) inner_boundaries = ET.SubElement(boundary, "InnerBoundaries") for fc_inner_b in fc_object.InnerBoundaries: ET.SubElement(inner_boundaries, "InnerBoundary").text = str(fc_inner_b.Id) self.write_shape(boundary, fc_object) is_hosted = fc_object.IsHosted ET.SubElement(boundary, "IsHosted").text = "true" if is_hosted else "false" if not is_hosted and fc_object.PhysicalOrVirtualBoundary != "VIRTUAL": for geo_type in ("SIA_Interior", "SIA_Exterior"): geo = ET.SubElement(boundary, geo_type) fc_geo = getattr(fc_object, geo_type) self.write_shape(geo, fc_geo) def write_building_elements(self, fc_object): building_element = ET.SubElement(self.building_elements, "BuildingElement") self.write_root_attrib(building_element, fc_object) ET.SubElement(building_element, "Thickness").text = str( fc_object.Thickness.Value / SCALE ) boundaries = ET.SubElement(building_element, "ProvidesBoundaries") for element_id in fc_object.ProvidesBoundaries: ET.SubElement(boundaries, "Id").text = str(element_id) @staticmethod def write_shape(xml_element, fc_object): geom = ET.SubElement(xml_element, "geom") for wire in fc_object.Proxy.get_wires(fc_object): polygon = ET.SubElement(geom, "Polygon") for vertex in wire.Vertexes: ET.SubElement(polygon, "point", vector_to_dict(vertex.Point)) ET.SubElement(xml_element, "Area").text = fc_area_to_si_xml(fc_object.Area) ET.SubElement(xml_element, "AreaWithHosted").text = fc_area_to_si_xml( fc_object.AreaWithHosted ) @staticmethod def append_id_element(xml_element, fc_object, name): value = getattr(fc_object, name) ET.SubElement(xml_element, name).text = str(value.Id) if value else "" @staticmethod def append_text_element(xml_element, fc_object, name): ET.SubElement(xml_element, name).text = getattr(fc_object, name) def write_to_file(self, full_path): self.tree.write(full_path, encoding="UTF-8", xml_declaration=True) def tostring(self): # Return a bytes # return ET.tostring(self.root, encoding="utf8", method="xml") # Return a string return ET.tostring(self.root, encoding="unicode") def vector_to_dict(vector): """Convert a FreeCAD.Vector into a dict to write it as attribute in xml""" return {key: str(getattr(vector, key) / SCALE) for key in ("x", "y", "z")} def fc_area_to_si_xml(fc_area): return str(fc_area.Value / SCALE ** 2) if __name__ == "__main__": OUTPUT = BEMxml() OUTPUT.write_project(None) OUTPUT_FILE = "output.xml" OUTPUT.tree.write(OUTPUT_FILE, encoding="UTF-8", xml_declaration=True) diff --git a/FreeCAD/BIMxBEM/freecad/bem/boundaries.py b/FreeCAD/BIMxBEM/freecad/bem/boundaries.py index c9ecf0a..a7819ee 100644 --- a/FreeCAD/BIMxBEM/freecad/bem/boundaries.py +++ b/FreeCAD/BIMxBEM/freecad/bem/boundaries.py @@ -1,1571 +1,1579 @@ # coding: utf8 -"""This module reads IfcRelSpaceBoundary from an IFC file and display them in FreeCAD""" +"""This module reads IfcRelSpaceBoundary from an IFC file and display them in FreeCAD + +© All rights reserved. +ECOLE POLYTECHNIQUE FEDERALE DE LAUSANNE, Switzerland, Laboratory CNPA, 2019-2020 + +See the LICENSE.TXT file for more details. + +Author : Cyril Waechter +""" import os import itertools import logging from collections import namedtuple import ifcopenshell import ifcopenshell.geom import FreeCAD import Part import FreeCADGui from freecad.bem.bem_xml import BEMxml LOG_FORMAT = "{levelname} {asctime} {funcName}-{message}" logging.basicConfig( filename="./boundaries.log", level=logging.WARNING, format=LOG_FORMAT, style="{" ) logger = logging.getLogger() 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): """Generate Space and RelSpaceBoundaries as defined in ifc_file. No post process.""" 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"): try: face = RelSpaceBoundary.create(ifc_entity=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.Id) face.RelatingSpace = fc_space.Id except ShapeCreationError: logger.warning( f"Failed to create fc_shape for RelSpaceBoundary <{ifc_boundary.id()}> even with fallback methode _part_by_mesh. IfcOpenShell bug ?" ) class ShapeCreationError(RuntimeError): pass 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"): if element.BoundedBy: 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, ReferenceError): 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) # 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" # Associate hosted elements associate_inner_boundaries(fc_boundaries, doc) def processing_sia_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): ensure_hosted_element_are(space, doc) join_over_splitted_boundaries(space, doc) find_closest_edges(space) set_leso_type(space) create_sia_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.SecondLevel.Group: bem_xml.write_boundary(boundary) for building_element in get_elements(doc): 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 join_over_splitted_boundaries(space, doc=FreeCAD.ActiveDocument): 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 = dict() for rel_boundary in boundaries: try: key = f"{rel_boundary.RelatedBuildingElement.Id}" except AttributeError: if rel_boundary.PhysicalOrVirtualBoundary == "VIRTUAL": logger.info("IfcElement %s is VIRTUAL. Modeling error ?") key = None 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) 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 if len(boundary_list) == 1: continue coplanar_boundaries = list([]) for boundary in boundary_list: if not coplanar_boundaries: coplanar_boundaries.append([boundary]) continue 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) break 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. try: join_boundaries(coplanar_list, doc) except Part.OCCError: logger.warning( f"Cannot join boundaries in space <{space.Id}> with key <{key}>" ) def join_boundaries(boundaries: list, doc=FreeCAD.ActiveDocument): """Try to join coplanar boundaries""" result_boundary = boundaries.pop() inner_wires = get_inner_wires(result_boundary)[:] vectors1 = get_boundary_outer_vectors(result_boundary) junction_found = True remove_from_doc = list() def find_and_join(): for boundary2 in boundaries: vectors2 = get_boundary_outer_vectors(boundary2) for ei1, ei2 in itertools.product( range(len(vectors1)), range(len(vectors2)) ): # 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).Length < TOLERANCE ): continue # Check in which order vectors1 and vectors2 should be connected if dir0.isEqual(dir1, TOLERANCE): p0_1_next_point, other_point = p1_1, p1_2 reverse_new_points = True else: p0_1_next_point, other_point = p1_2, 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) and dir0.negative().dot(other_point - p0_2) < dir0.negative().dot(p0_1 - p0_2) ): 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)) if not result_boundary.IsHosted: for inner_boundary in boundary2.InnerBoundaries: append(result_boundary, "InnerBoundaries", inner_boundary) inner_boundary.ParentBoundary = result_boundary.Id boundaries.remove(boundary2) remove_from_doc.append(boundary2) return True else: logger.warning( f"Unable to join boundaries RelSpaceBoundary Id <{result_boundary.Id}> with boundaries <{(b.Id for b in boundaries)}>" ) return False while boundaries and junction_found: junction_found = find_and_join() common_edge_found = True while common_edge_found: for ei1, ei2 in itertools.combinations(range(len(vectors1)), 2): # retrieves points from previous edge to next edge included. p0_1, p0_2 = (vectors1[ei1], vectors1[(ei1 + 1) % len(vectors1)]) p1_1, p1_2 = (vectors1[ei2], vectors1[(ei2 + 1) % len(vectors1)]) 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. same_dir = dir0.isEqual(dir1, TOLERANCE) if not ( (same_dir or dir0.isEqual(-dir1, TOLERANCE)) and v0_12.cross(p1_1 - p0_1).Length < TOLERANCE ): continue # Check in which order vectors1 and vectors2 should be connected if same_dir: p0_1_next_point, other_point = p1_1, p1_2 reverse_new_points = True else: p0_1_next_point, other_point = p1_2, 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) and dir0.negative().dot(other_point - p0_2) < dir0.negative().dot(p0_1 - p0_2) ): continue # join vectors1 and vectors2 at indexes vectors_split1 = vectors1[: ei1 + 1] + vectors1[ei2 + 1 :] vectors_split2 = vectors1[ei1 + 1 : ei2 + 1] clean_vectors(vectors_split1) clean_vectors(vectors_split2) area1 = Part.Face( Part.makePolygon(vectors_split1 + [vectors_split1[0]]) ).Area area2 = Part.Face( Part.makePolygon(vectors_split2 + [vectors_split2[0]]) ).Area if area1 > area2: vectors1 = vectors_split1 inner_vectors = vectors_split2 else: vectors1 = vectors_split2 inner_vectors = vectors_split1 close_vectors(inner_vectors) inner_wires.extend([Part.makePolygon(inner_vectors)]) common_edge_found = True break else: common_edge_found = False # Replace existing shape with joined shapes close_vectors(vectors1) outer_wire = Part.makePolygon(vectors1) generate_boundary_compound(result_boundary, outer_wire, inner_wires) # Clean FreeCAD document if join operation was a success for fc_object in remove_from_doc: doc.removeObject(fc_object.Name) def generate_boundary_compound(boundary, outer_wire: Part.Wire, inner_wires: list): """Generate boundary compound composed of 1 Face, 1 OuterWire, 0-n InnerWires""" face = Part.Face(outer_wire) for inner_wire in inner_wires: new_face = face.cut(Part.Face(inner_wire)) if not new_face.Area: b_id = boundary.Id if isinstance(boundary, Root) else boundary.SourceBoundary logger.warning( f"Failure. An inner_wire did not cut face correctly in boundary <{b_id}>. OuterWire area = {Part.Face(outer_wire).Area / 10 ** 6}, InnerWire area = {Part.Face(inner_wire).Area / 10 ** 6}" ) continue face = new_face boundary.Shape = Part.Compound([face, outer_wire, *inner_wires]) def ensure_hosted_element_are(space, doc=FreeCAD.ActiveDocument): for boundary in space.SecondLevel.Group: try: ifc_type = boundary.RelatedBuildingElement.IfcType except AttributeError: continue if not is_typically_hosted(ifc_type): continue if boundary.IsHosted and boundary.ParentBoundary: continue def find_host(boundary): fallback_solution = None normal = get_normal_at(boundary) for boundary2 in space.SecondLevel.Group: if not normal.isEqual(get_normal_at(boundary2), TOLERANCE): continue fallback_solution = boundary2 for inner_wire in get_inner_wires(boundary2): if ( not abs(Part.Face(inner_wire).Area - boundary.Area.Value) < TOLERANCE ): continue return boundary2 else: 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 try: host = find_host(boundary) except HostNotFound as err: logger.exception(err) boundary.IsHosted = True boundary.ParentBoundary = host.Id append(host, "InnerBoundaries", boundary) def is_typically_hosted(ifc_type: str): """Say if given ifc_type is typically hosted eg. windows, doors""" usually_hosted_types = ("IfcWindow", "IfcDoor") for usual_type in usually_hosted_types: if ifc_type.startswith(usual_type): return True else: return False class HostNotFound(LookupError): pass 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.isEqual(p3, TOLERANCE) or p1.isEqual(p2, TOLERANCE) or are_3points_collinear(p1, p2, p3) ): vectors.remove(p2) continue i += 1 def get_wires(boundary): return (s for s in boundary.Shape.SubShapes if isinstance(s, Part.Wire)) def get_outer_wire(boundary): return [s for s in boundary.Shape.SubShapes if isinstance(s, Part.Wire)][0] def get_inner_wires(boundary): return [s for s in boundary.Shape.SubShapes if isinstance(s, Part.Wire)][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 get_outer_wire(boundary).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): """Find closest boundary and edge to be able to reconstruct a closed shell""" boundaries = [b for b in space.SecondLevel.Group if not b.IsHosted] Closest = namedtuple("Closest", ["boundary", "edge", "distance"]) # Initialise defaults values for boundary in boundaries: n_edges = len(get_outer_wire(boundary).Edges) boundary.Proxy.closest = [ Closest(boundary=-1, edge=-1, distance=100000) ] * n_edges def compare_closest(boundary1, ei1, edge1, boundary2, ei2, edge2): is_closest = False closest_boundary, closest_edge, distance = boundary1.Proxy.closest[ei1] edge_to_edge = compute_distance(edge1, edge2) if distance <= TOLERANCE: return # Perfect match if edge_to_edge <= TOLERANCE: is_closest = True elif edge_to_edge - distance - TOLERANCE <= 0: # Case 1 : boundaries point in same direction so all solution are valid. dot_dir = get_normal_at(boundary2).dot(get_normal_at(boundary1)) if abs(dot_dir) >= 1 - TOLERANCE: is_closest = True # Case 2 : boundaries intersect else: # Check if projection on plane intersection cross boundary1. If so edge2 cannot be a valid solution. pnt1 = edge1.CenterOfMass plane_intersect = get_plane(boundary1).intersect(get_plane(boundary2))[ 0 ] v_ab = plane_intersect.Direction v_ap = pnt1 - plane_intersect.Location pnt2 = pnt1 + FreeCAD.Vector().projectToLine(v_ap, v_ab) try: projection_edge = Part.makeLine(pnt1, pnt2) common = projection_edge.common(boundary1.Shape.Faces[0]) if common.Length <= TOLERANCE: is_closest = True # Catch case where pnt1 == pnt2 which is fore sure a valid solution. except Part.OCCError: is_closest = True if is_closest: boundary1.Proxy.closest[ei1] = Closest(boundary2, ei2, edge_to_edge) # Loop through all boundaries and edges to find the closest edge for boundary1, boundary2 in itertools.combinations(boundaries, 2): # If boundary1 and boundary2 are facing an opposite direction no match possible if get_normal_at(boundary2).dot(get_normal_at(boundary1)) <= -1 + TOLERANCE: continue edges1 = get_outer_wire(boundary1).Edges edges2 = get_outer_wire(boundary2).Edges for (ei1, edge1), (ei2, edge2) in itertools.product( enumerate(edges1), enumerate(edges2) ): if not is_low_angle(edge1, edge2): continue compare_closest(boundary1, ei1, edge1, boundary2, ei2, edge2) compare_closest(boundary2, ei2, edge2, boundary1, ei1, edge1) # Store found values in standard FreeCAD properties for boundary in boundaries: closest_boundaries, boundary.ClosestEdges, closest_distances = ( list(i) for i in zip(*boundary.Proxy.closest) ) boundary.ClosestBoundaries = [b.Id for b in closest_boundaries] boundary.ClosestDistance = [int(d) for d in closest_distances] def set_leso_type(space): for boundary in space.SecondLevel.Group: boundary.LesoType = define_leso_type(boundary) def define_leso_type(boundary): try: ifc_type = boundary.RelatedBuildingElement.IfcType except AttributeError: if boundary.PhysicalOrVirtualBoundary != "VIRTUAL": logger.warning(f"Unable to defin 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 "Façade" elif ifc_type.startswith("IfcSlab") or ifc_type == "IfcRoof": # Pointing up => Ceiling. Pointing down => Flooring if boundary.Shape.Faces[0].normalAt(0, 0).z > 0: return "Ceiling" return "Flooring" else: logger.warning(f"Unable to define LesoType for Boundary Id <{boundary.Id}>") return "Unknown" 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.Id def associate_inner_boundaries(fc_boundaries, doc): """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( get_boundaries_by_element_id( fc_boundary.RelatedBuildingElement.HostElement, doc ) ) # If there is more than 1 candidate it doesn't really matter # as they share the same host element and space try: host_element = candidates.pop() except KeyError: logger.warning( f"RelSpaceBoundary Id<{fc_boundary.Id}> is hosted but host not found. Investigations required." ) continue fc_boundary.ParentBoundary = host_element.Id 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, doc) 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, get_normal_at(fc_boundary)) def get_normal_at(fc_boundary, at=(0, 0)): return fc_boundary.Shape.Faces[0].normalAt(*at) 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, doc): if fc_boundary.PhysicalOrVirtualBoundary == "VIRTUAL": return [] other_boundaries = get_boundaries_by_element( fc_boundary.RelatedBuildingElement, doc ) other_boundaries.remove(fc_boundary) return [ b for b in other_boundaries if not b.CorrespondingBoundary or b.RelatingSpace != fc_boundary.RelatingSpace ] def get_boundaries_by_element(element, doc): return [ boundary for boundary in get_elements_by_ifctype("IfcRelSpaceBoundary", doc) if boundary.RelatedBuildingElement == element ] def get_boundaries_by_element_id(element_id, doc): def _compare_related_id(boundary, id): try: return boundary.RelatedBuildingElement.Id == element_id except AttributeError: if boundary.PhysicalOrVirtualBoundary != "VIRTUAL": logger.warning( f"RelSpaceBoundary Id<{boundary.Id}> is not VIRTUAL and has\ no RelatedBuildingElement. Investigations required" ) return False return [ boundary for boundary in get_elements_by_ifctype("IfcRelSpaceBoundary", doc) if _compare_related_id(boundary, element_id) ] def associate_corresponding_boundary(fc_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 ( fc_boundary.InternalOrExternalBoundary != "INTERNAL" or fc_boundary.CorrespondingBoundary ): return other_boundaries = clean_corresponding_candidates(fc_boundary, doc) if len(other_boundaries) == 1: corresponding_boundary = other_boundaries[0] else: center_of_mass = get_outer_wire(fc_boundary).CenterOfMass min_lenght = 10000 # No element has 10 m for boundary in other_boundaries: distance = center_of_mass.distanceToPoint( get_outer_wire(boundary).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 logger.warning(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: logger.warning(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_sia_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 space in get_elements_by_ifctype("IfcSpace", doc): create_sia_ext_boundaries(space, is_from_revit, is_from_archicad) create_sia_int_boundaries(space, is_from_revit, is_from_archicad) rejoin_boundaries(space, "SIA_Exterior") rejoin_boundaries(space, "SIA_Interior") 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 """ base_boundaries = space.SecondLevel.Group for base_boundary in base_boundaries: lines = [] boundary1 = getattr(base_boundary, sia_type) if ( base_boundary.IsHosted or base_boundary.PhysicalOrVirtualBoundary == "VIRTUAL" ): continue for b2_id, (ei1, ei2) in zip( base_boundary.ClosestBoundaries, enumerate(base_boundary.ClosestEdges) ): boundary2 = getattr(get_in_list_by_id(base_boundaries, b2_id), sia_type) if not boundary2: logger.warning(f"Cannot find corresponding boundary with id <{b2_id}>") lines.append(line_from_edge(get_outer_wire(base_boundary).Edges[ei1])) continue base_plane = get_plane(boundary1) # Case 1 : boundaries are not parallel plane_intersect = base_plane.intersect(get_plane(boundary2)) if plane_intersect: lines.append(plane_intersect[0]) continue # Case 2 : boundaries are parallel line1 = line_from_edge(get_outer_wire(boundary1).Edges[ei1]) try: line2 = line_from_edge(get_outer_wire(boundary2).Edges[ei2]) except IndexError: logger.warning(f"Cannot find closest edge index <{ei2}> in boundary id <{b2_id}> to rejoin boundary <{base_boundary.Id}>") lines.append(line_from_edge(get_outer_wire(base_boundary).Edges[ei1])) continue # Case 2a : edges are not parallel line_intersect = line1.intersect2d(line2, base_plane) if line_intersect: point1 = line_intersect[0] # TODO: Investigate to see if line.intersect2d(line2, base_plane) might cause some real issues if not isinstance(point1, FreeCAD.Vector): point1 = FreeCAD.Vector(*point1) 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: lines.append(Part.Line(point1, point2)) except Part.OCCError as err: logger.exception(f"Failure in boundary id <{base_boundary.Id}> {point1} and {point2} are equal") # Generate new shape try: outer_wire = polygon_from_lines(lines) except NoIntersectionError: # TODO: Investigate to see why this happens logger.exception(f"Unable to rejoin boundary Id <{base_boundary.Id}>") continue except Part.OCCError as e: logger.exception(f"Invalid geometry while rejoining boundary Id <{base_boundary.Id}>") continue try: face = Part.Face(outer_wire) except Part.OCCError: logger.exception(f"Unable to rejoin boundary Id <{base_boundary.Id}>") continue inner_wires = get_inner_wires(boundary1) generate_boundary_compound(boundary1, outer_wire, inner_wires) boundary1.Area = area = boundary1.Shape.Area for inner_boundary in base_boundary.InnerBoundaries: area = area + inner_boundary.Shape.Area boundary1.AreaWithHosted = area def get_in_list_by_id(elements, element_id): for element in elements: if element.Id == element_id: return element else: raise LookupError(f"No element with Id <{element_id}> found") def create_sia_ext_boundaries(space, is_from_revit, is_from_archicad): """Create SIA boundaries from RelSpaceBoundaries and translate it if necessary""" sia_group_obj = space.Boundaries.newObject( "App::DocumentObjectGroup", "SIA_Exteriors" ) space.SIA_Exteriors = sia_group_obj for boundary1 in space.SecondLevel.Group: if boundary1.IsHosted or boundary1.PhysicalOrVirtualBoundary == "VIRTUAL": continue bem_boundary = BEMBoundary.create(boundary1, "SIA_Exterior") sia_group_obj.addObject(bem_boundary) thickness = boundary1.RelatedBuildingElement.Thickness.Value ifc_type = boundary1.RelatedBuildingElement.IfcType normal = boundary1.Shape.Faces[0].normalAt(0, 0) # if is_from_archicad: # normal = -normal # EXTERNAL: there is multiple possible values for external so testing internal is better. if boundary1.InternalOrExternalBoundary != "INTERNAL": lenght = thickness if is_from_revit and ifc_type.startswith("IfcWall"): lenght /= 2 bem_boundary.Placement.move(normal * lenght) # INTERNAL. TODO: Check during tests if NOTDEFINED case need to be handled ? else: type1 = {"IfcSlab"} if ifc_type in type1: lenght = thickness / 2 else: if is_from_revit: continue lenght = thickness / 2 bem_boundary.Placement.move(normal * lenght) def create_sia_int_boundaries(space, is_from_revit, is_from_archicad): """Create boundaries necessary for SIA calculations""" sia_group_obj = space.Boundaries.newObject( "App::DocumentObjectGroup", "SIA_Interiors" ) space.SIA_Interiors = sia_group_obj for boundary in space.SecondLevel.Group: if boundary.IsHosted or boundary.PhysicalOrVirtualBoundary == "VIRTUAL": continue normal = boundary.Shape.Faces[0].normalAt(0, 0) if is_from_archicad: normal = -normal bem_boundary = BEMBoundary.create(boundary, "SIA_Interior") sia_group_obj.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 line_from_edge(edge: Part.Edge) -> Part.Line: points = [v.Point for v in edge.Vertexes] return Part.Line(*points) def polygon_from_lines(lines): new_points = [] for line1, line2 in zip(lines, lines[1:] + lines[:1]): try: # Need to ensure direction are not same to avoid crash if abs(line1.Direction.dot(line2.Direction)) >= 1 - TOLERANCE: continue new_points.append(line1.intersectCC(line2, 1)[0].toShape().Point) except IndexError: raise NoIntersectionError new_points[0:0] = new_points[-1:] return Part.makePolygon(new_points) class NoIntersectionError(IndexError): pass 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}") try: return _part_by_mesh( space_boundary.ConnectionGeometry.SurfaceOnRelatingElement ) except RuntimeError: raise ShapeCreationError 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""" inner_wires = list() outer_wire = _polygon_by_mesh(ifc_entity.OuterBoundary) face = Part.Face(outer_wire) try: inner_boundaries = ifc_entity.InnerBoundaries for inner_boundary in tuple(inner_boundaries) if inner_boundaries else tuple(): inner_wire = _polygon_by_mesh(inner_boundary) face = face.cut(Part.Face(inner_wire)) inner_wires.append(inner_wire) except RuntimeError: pass fc_shape = Part.Compound([face, outer_wire, *inner_wires]) 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) 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", "IfcName", 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() obj.IfcName = ifc_entity.Name 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::PropertyInteger", "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::PropertyInteger", "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", "SIA_Interior", bem_category) obj.addProperty("App::PropertyLink", "SIA_Exterior", bem_category) obj.addProperty( "App::PropertyEnumeration", "LesoType", bem_category ).LesoType = [ "Ceiling", "Façade", "Flooring", "Window", "Door", "Unknown", ] if not ifc_entity: return if FreeCAD.GuiUp: 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) obj.LesoType = "Unknown" @staticmethod def create(obj_name: str = "RelSpaceBoundary", ifc_entity=None): """Stantard FreeCAD FeaturePython Object creation method""" obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", obj_name) RelSpaceBoundary(obj, ifc_entity) try: # ViewProviderRelSpaceBoundary(obj.ViewObject) obj.ViewObject.Proxy = 0 except AttributeError: FreeCAD.Console.PrintLog("No ViewObject ok if running with no Gui") return obj 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.id(), ifc_entity.RelatedBuildingElement.Name, ) except AttributeError: obj.Label = f"{ifc_entity.id()} VIRTUAL" if ifc_entity.PhysicalOrVirtualBoundary != "VIRTUAL": logger.warning( f"{ifc_entity.id()} is not VIRTUAL and has no RelatedBuildingElement" ) @staticmethod def get_wires(obj): return get_wires(obj) 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::PropertyIntegerList", "ProvidesBoundaries", ifc_attributes ) obj.addProperty("App::PropertyLinkList", "HostedElements", bem_category) obj.addProperty("App::PropertyInteger", "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) class BEMBoundary: def __init__(self, obj, boundary): self.Type = "BEMBoundary" obj.Proxy = self category_name = "BEM" obj.addProperty("App::PropertyInteger", "SourceBoundary", category_name) obj.SourceBoundary = boundary.Id obj.addProperty("App::PropertyArea", "Area", category_name) obj.addProperty("App::PropertyArea", "AreaWithHosted", category_name) obj.Shape = boundary.Shape.copy() self.set_label(obj, boundary) @staticmethod def create(boundary, geo_type): """Stantard FreeCAD FeaturePython Object creation method""" obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", "BEMBoundary") BEMBoundary(obj, boundary) setattr(boundary, geo_type, obj) try: # ViewProviderRelSpaceBoundary(obj.ViewObject) 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 @staticmethod def set_label(obj, source_boundary): obj.Label = source_boundary.Label @staticmethod def get_wires(obj): return get_wires(obj) def create_container_from_entity(ifc_entity): """Stantard FreeCAD FeaturePython Object creation method""" obj_name = ifc_entity.is_a() obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", obj_name) Container(obj, ifc_entity) if FreeCAD.GuiUp: obj.ViewObject.Proxy = ViewProviderRoot(obj.ViewObject) obj.ViewObject.DisplayMode = "Wireframe" 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) obj.ViewObject.DisplayMode = "Wireframe" 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) obj.ViewObject.DisplayMode = "Wireframe" 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 def generate_bem_xml_from_file(ifc_path: str, gui_up: bool = False): doc = FreeCAD.newDocument() generate_ifc_rel_space_boundaries(ifc_path, doc) processing_sia_boundaries(doc) return write_xml(doc).tostring() if __name__ == "__main__": if os.name == "nt": TEST_FOLDER = r"C:\git\BIMxBEM\IfcTestFiles" else: TEST_FOLDER = "/home/cyril/git/BIMxBEM/IfcTestFiles/" TEST_FILES = { 0: "Triangle_2x3_A23.ifc", 1: "Triangle_2x3_R19.ifc", 2: "2Storey_2x3_A22.ifc", 3: "2Storey_2x3_R19.ifc", 4: "0014_Vernier112D_ENE_ModèleÉnergétique_R20.ifc", 6: "Investigation_test_R19.ifc", 7: "OverSplitted_R20_2x3.ifc", 8: "ExternalEarth_R20_2x3.ifc", 9: "ExternalEarth_R20_IFC4.ifc", } IFC_PATH = os.path.join(TEST_FOLDER, TEST_FILES[4]) 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) processing_sia_boundaries(DOC) bem_xml = write_xml(DOC) output_xml_to_path(bem_xml) # xml_str = generate_bem_xml_from_file(IFC_PATH) FreeCADGui.activeView().viewIsometric() FreeCADGui.SendMsgToActiveView("ViewFit") FreeCADGui.exec_loop() diff --git a/FreeCAD/BIMxBEM/freecad/bem/commands.py b/FreeCAD/BIMxBEM/freecad/bem/commands.py index e323260..226f588 100644 --- a/FreeCAD/BIMxBEM/freecad/bem/commands.py +++ b/FreeCAD/BIMxBEM/freecad/bem/commands.py @@ -1,132 +1,142 @@ +# coding: utf8 +"""This module create commands for FreeCAD + +© All rights reserved. +ECOLE POLYTECHNIQUE FEDERALE DE LAUSANNE, Switzerland, Laboratory CNPA, 2019-2020 + +See the LICENSE.TXT file for more details. + +Author : Cyril Waechter +""" import os import FreeCAD from freecad.bem import boundaries class ImportRelSpaceBoundary: def IsActive(self): return bool(FreeCAD.ActiveDocument) def GetResources(self): return { "Pixmap": "icon.svg", "MenuText": "Import", "ToolTip": "Import IfcRelSpaceBoundary for selected IFC file", } def Activated(self): 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[0]) boundaries.generate_ifc_rel_space_boundaries( ifc_path, doc=FreeCAD.ActiveDocument ) return class GenerateBemBoundaries: def IsActive(self): return bool(FreeCAD.ActiveDocument) def GetResources(self): return { "Pixmap": "icon.svg", "MenuText": "Generate SIA boundaries", "ToolTip": "Generate SIA specific BEM boundaries", } def Activated(self): boundaries.processing_sia_boundaries() return class WriteToXml: def IsActive(self): return bool(FreeCAD.ActiveDocument) def GetResources(self): return { "Pixmap": "icon.svg", "MenuText": "Import", "ToolTip": "Import IfcRelSpaceBoundary for selected IFC file", } def Activated(self): return class DisplaySIAInt: def IsActive(self): return bool(FreeCAD.ActiveDocument) def GetResources(self): return { "Pixmap": "icon.svg", "MenuText": "Display SIA Interior boundaries", "ToolTip": "Display SIA Interior boundaries", } def Activated(self): doc = FreeCAD.ActiveDocument display_only(doc, "SIA_Interiors") return class DisplaySIAExt: def IsActive(self): return bool(FreeCAD.ActiveDocument) def GetResources(self): return { "Pixmap": "icon.svg", "MenuText": "Display SIA Exterior boundaries", "ToolTip": "Display SIA Exterior boundaries", } def Activated(self): doc = FreeCAD.ActiveDocument display_only(doc, "SIA_Exteriors") return def display_only(doc, sia_type): for element in doc.findObjects(): element.Visibility = False for group_obj in doc.findObjects("App::DocumentObjectGroup"): if group_obj.Label.startswith(sia_type): for element in group_obj.Group: element.Visibility = True class DisplayAll: def IsActive(self): return bool(FreeCAD.ActiveDocument) def GetResources(self): return { "Pixmap": "icon.svg", "MenuText": "Display all boundaries", "ToolTip": "Display all boundaries", } def Activated(self): doc = FreeCAD.ActiveDocument for group_obj in doc.findObjects("App::DocumentObjectGroup"): if group_obj.Label.startswith("Boundaries"): for sub_group_obj in group_obj.Group: for element in sub_group_obj.Group: element.Visibility = True display_only(doc, "SIA_Exteriors") return \ No newline at end of file