diff --git a/freecad/bem/bem_xml.py b/freecad/bem/bem_xml.py index c840ff0..db60417 100644 --- a/freecad/bem/bem_xml.py +++ b/freecad/bem/bem_xml.py @@ -1,280 +1,289 @@ # coding: utf8 """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 import typing import FreeCAD if typing.TYPE_CHECKING: from freecad.bem.entities import * 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.zones = ET.SubElement(self.root, "Zones") self.spaces = ET.SubElement(self.root, "Spaces") self.boundaries = ET.SubElement(self.root, "Boundaries") self.building_element_types = ET.SubElement(self.root, "BuildingElementTypes") self.building_elements = ET.SubElement(self.root, "BuildingElements") self.materials = ET.SubElement(self.root, "Materials") self.sites = None self.buildings = None self.storeys = None @staticmethod def write_id(xml_element, fc_object): ET.SubElement(xml_element, "Id").text = str(fc_object.Id) @staticmethod def write_name(xml_element, fc_object): ET.SubElement(xml_element, "Name").text = fc_object.IfcName @staticmethod def write_description(xml_element, fc_object): ET.SubElement(xml_element, "Description").text = fc_object.Description def write_root_attrib(self, xml_element, fc_object): self.write_id(xml_element, fc_object) ET.SubElement(xml_element, "GlobalId").text = fc_object.GlobalId self.write_name(xml_element, fc_object) self.write_description(xml_element, fc_object) ET.SubElement(xml_element, "IfcType").text = fc_object.IfcType @staticmethod def write_attributes(parent, fc_object, attributes): for attrib in attributes: value = getattr(fc_object, attrib) if isinstance(value, FreeCAD.Units.Quantity): if value.Unit.Type == "Length": value = value.Value / SCALE else: value = value.Value ET.SubElement(parent, attrib).text = str(value or "") def write_project(self, fc_object: "ProjectFeature") -> None: 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", unitary_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_zone(self, fc_object: "ProjectFeature") -> None: + zone = ET.SubElement(self.zones, "Zone") + self.write_root_attrib(zone, fc_object) + ET.SubElement(zone, "LongName").text = fc_object.LongName + spaces = ET.SubElement(zone, "Spaces") + for space in fc_object.RelatedObjects: + ET.SubElement(spaces, "Space").text = str(space.Id) + def write_site(self, fc_object: "ContainerFeature") -> None: 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: "ContainerFeature") -> None: 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: "ContainerFeature") -> None: 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: "SpaceFeature") -> None: space = ET.SubElement(self.spaces, "Space") self.write_root_attrib(space, fc_object) self.write_attributes(space, fc_object, ("LongName",)) 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: "RelSpaceBoundaryFeature") -> None: boundary = ET.SubElement(self.boundaries, "Boundary") self.write_root_attrib(boundary, fc_object) ET.SubElement(boundary, "Normal", unitary_vector_to_dict(fc_object.Normal)) 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.Id) if fc_object.ParentBoundary else "" ) ET.SubElement(boundary, "RelatingSpace").text = ( str(fc_object.RelatingSpace.Id) 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) self.write_attributes(boundary, fc_object, ("UndergroundDepth",)) is_hosted = fc_object.IsHosted ET.SubElement(boundary, "IsHosted").text = "true" if is_hosted else "false" value = fc_object.InternalToExternal param = ET.SubElement(boundary, "InternalToExternal") if value == 1: param.text = "true" elif value == -1: param.text = "false" else: param.text = "null" 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_element_types(self, fc_object): building_element_types = ET.SubElement( self.building_element_types, "BuildingElementType" ) self.write_root_attrib(building_element_types, fc_object) ET.SubElement(building_element_types, "Thickness").text = str( fc_object.Thickness.Value / SCALE ) ET.SubElement(building_element_types, "ThermalTransmittance").text = str( fc_object.ThermalTransmittance or "" ) occurences = ET.SubElement(building_element_types, "ApplicableOccurrence") for element in fc_object.ApplicableOccurrence: ET.SubElement(occurences, "Id").text = str(element.Id) if fc_object.Material: ET.SubElement(building_element_types, "Material").text = str( fc_object.Material.Id or "" ) 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, "IsTypedBy").text = str( getattr(fc_object.IsTypedBy, "Id", "") ) ET.SubElement(building_element, "Thickness").text = str( fc_object.Thickness.Value / SCALE ) ET.SubElement(building_element, "ThermalTransmittance").text = str( fc_object.ThermalTransmittance or "" ) boundaries = ET.SubElement(building_element, "ProvidesBoundaries") for element in fc_object.ProvidesBoundaries: ET.SubElement(boundaries, "Id").text = str(element.Id) if fc_object.Material: ET.SubElement(building_element, "Material").text = str( fc_object.Material.Id or "" ) def write_class_attrib(self, xml_element, fc_object): self.write_attributes(xml_element, fc_object, fc_object.Proxy.attributes) @staticmethod def write_psets(xml_element, fc_object): for props in fc_object.Proxy.psets_dict.values(): for prop in props: ET.SubElement(xml_element, prop).text = str(getattr(fc_object, prop)) def write_material(self, fc_object): proxy = fc_object.Proxy material = ET.SubElement(self.materials, type(proxy).__name__) self.write_id(material, fc_object) self.write_name(material, fc_object) self.write_description(material, fc_object) self.write_class_attrib(material, fc_object) self.write_psets(material, fc_object) if not proxy.part_name: return parts = ET.SubElement(material, proxy.parts_name) for values in zip(*[getattr(fc_object, prop) for prop in proxy.part_props]): part = ET.SubElement(parts, proxy.part_name) for i, value, attrib in zip(range(len(values)), values, proxy.part_attribs): if i == 0: ET.SubElement(part, attrib).text = str(value.Id) else: ET.SubElement(part, attrib).text = str(value) @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 unitary_vector_to_dict(vector): return {key: str(getattr(vector, key)) for key in ("x", "y", "z")} def fc_area_to_si_xml(fc_area): return str(fc_area.getValueAs("m^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/bem/boundaries.py b/freecad/bem/boundaries.py index 971898b..f0ccafd 100644 --- a/freecad/bem/boundaries.py +++ b/freecad/bem/boundaries.py @@ -1,988 +1,990 @@ # coding: utf8 """This module adapt IfcRelSpaceBoundary and create SIA specific bem boundaries 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 itertools import os from collections import namedtuple import typing from typing import NamedTuple, Iterable, List, Optional, Dict import ifcopenshell import ifcopenshell.geom import ifcopenshell.util.element import ifcopenshell.util.unit import FreeCAD import FreeCADGui import Part from freecad.bem import materials from freecad.bem.bem_xml import BEMxml from freecad.bem.bem_logging import logger, LOG_STREAM from freecad.bem.progress import Progress from freecad.bem import utils from freecad.bem.entities import ( RelSpaceBoundary, BEMBoundary, Element, ElementType, ) from freecad.bem.ifc_importer import IfcImporter, TOLERANCE if typing.TYPE_CHECKING: from freecad.bem.typing import ( SpaceFeature, ContainerFeature, ) # pylint: disable=no-name-in-module, import-error def processing_sia_boundaries(doc=FreeCAD.ActiveDocument) -> None: """Create SIA specific boundaries cf. https://www.sia.ch/fr/services/sia-norm/""" Progress.set(30, "ProcessingSIABoundaries_Prepare", Progress.new_space_count(), 40) for space in utils.get_elements_by_ifctype("IfcSpace", doc): ensure_hosted_element_are(space, doc) ensure_hosted_are_coplanar(space) compute_space_area(space) set_face_to_boundary_info(space) merge_over_splitted_boundaries(space, doc) handle_curtain_walls(space, doc) find_closest_edges(space) set_leso_type(space) ensure_external_earth_is_set(space, doc) Progress.set() Progress.set(70, "ProcessingSIABoundaries_Create", Progress.new_space_count(), 20) ensure_materials_layers_order(doc) create_sia_boundaries(doc) doc.recompute() def reverse_layers(material): material.MaterialLayers = material.MaterialLayers[::-1] material.Thicknesses = material.Thicknesses[::-1] def set_internal_to_external(element, material): axis = utils.get_axis_by_name(element.Placement, material.LayerSetDirection) if material.DirectionSense == "NEGATIVE": axis = -axis for boundary in element.ProvidesBoundaries: # Check if already set if boundary.InternalToExternal: continue # Flooring always from top to bottom (agreement not in IFC standards) if boundary.CorrespondingBoundary and boundary.LesoType == "Ceiling": continue if boundary.LesoType == "Flooring": if axis.z > 0: reverse_layers(material) boundary.InternalToExternal = 1 if boundary.CorrespondingBoundary: boundary.CorrespondingBoundary.InternalToExternal = -1 # External always from interior to exterior (agreement not in IFC standards) elif boundary.InternalOrExternalBoundary != "INTERNAL": if axis.dot(boundary.Normal) < 0: reverse_layers(material) boundary.InternalToExternal = 1 # Other internal boundaries else: if axis.dot(boundary.Normal) < 0: boundary.InternalToExternal = -1 else: boundary.InternalToExternal = 1 if boundary.CorrespondingBoundary: boundary.CorrespondingBoundary.InternalToExternal = ( -boundary.InternalToExternal ) def ensure_materials_layers_order(doc): """ There is no convention for material order in IFC but energy simulation software expect one. From interior to exterior for external shell. From top to bottom for internal slabs (flooring) """ for material in utils.get_by_class(doc, materials.LayerSet): boundaries = [] for element in material.AssociatedTo: try: set_internal_to_external(element, material) # Happen when element is an element type except AttributeError: for occurence in element.ApplicableOccurrence: if not element.Material: set_internal_to_external(occurence, material) def ensure_external_earth_is_set(space: "SpaceFeature", doc=FreeCAD.ActiveDocument): sites: List["ContainerFeature"] = list( utils.get_elements_by_ifctype("IfcSite", doc) ) ground_bound_box = get_ground_bound_box(sites) if space.Shape.BoundBox.ZMin - ground_bound_box.ZMax > 1000: return ground_shape = Part.Compound([]) for site in sites: ground_shape.add(site.Shape) if not ground_shape.BoundBox.isValid(): ground_shape = Part.Plane().toShape() for boundary in space.SecondLevel.Group: if boundary.InternalOrExternalBoundary in ( "INTERNAL", "EXTERNAL_EARTH", "EXTERNAL_WATER", "EXTERNAL_FIRE", ): continue if boundary.InnerBoundaries: continue if not is_underground(boundary, ground_shape): continue boundary.InternalOrExternalBoundary = "EXTERNAL_EARTH" def is_underground(boundary, ground_shape) -> bool: closest_points = ground_shape.distToShape(boundary.Shape)[1][0] direction: FreeCAD.Vector = closest_points[1] - closest_points[0] if direction.z > 1000: return False if boundary.LesoType == "Flooring": el_thickness = getattr( getattr(getattr(boundary, "RelatedBuildingElement", 0), "Thickness", 0), "Value", 0, ) if direction.z - el_thickness * 1.5 > 0: return False boundary.UndergroundDepth = abs(direction.z - el_thickness) return True if boundary.LesoType == "Wall": bbox = boundary.Shape.BoundBox if (bbox.ZMax + bbox.ZMin) / 2 + direction.z < 0: return True if boundary.LesoType == "Ceiling": if direction.z < TOLERANCE: return True return False def get_ground_bound_box(sites: Iterable["ContainerFeature"]) -> FreeCAD.BoundBox: boundbox = FreeCAD.BoundBox() for site in sites: boundbox.add(site.Shape.BoundBox) return boundbox if boundbox.isValid() else FreeCAD.BoundBox(0, 0, -30000, 0, 0, 0) class FaceToBoundary: def __init__(self, boundary, face): self.boundary = boundary self.face = face self.point_on_face = None self.point_on_boundary = None self.compute_shortest() self.boundary_normal = utils.get_boundary_normal( boundary, self.point_on_boundary ) self.face_normal = utils.get_face_normal(face, self.point_on_face) self.distance = self.vec_to_space.Length @property def vec_to_space(self): return self.point_on_face - self.point_on_boundary def compute_shortest(self): boundary_face = self.boundary.Shape.Faces[0] min_dist = self.face.distToShape(boundary_face) self.point_on_face = min_dist[1][0][0] self.point_on_boundary = min_dist[1][0][1] @property def is_valid(self): # Not valid face if its normal and boundary normal do not point in same direction return abs(self.boundary_normal.dot(self.face_normal)) > 1 - TOLERANCE @property def fixed_normal(self): return ( self.boundary_normal if self.face_normal.dot(self.boundary_normal) > 0 else -self.boundary_normal ) @property def translation_to_face(self): return self.face_normal * self.face_normal.dot(self.vec_to_space) def set_face_to_boundary_info(space): faces = space.Shape.Faces for boundary in space.SecondLevel.Group: if boundary.IsHosted: continue candidates = (FaceToBoundary(boundary, face) for face in faces) result = min(candidates, key=lambda x: x.distance if x.is_valid else 10000) boundary.TranslationToSpace = result.translation_to_face normal = result.fixed_normal boundary.Normal = normal for hosted in boundary.InnerBoundaries: hosted.Normal = normal def compute_space_area(space: Part.Feature): """Compute both gross and net area""" z_min = space.Shape.BoundBox.ZMin z_sre = z_min + 1000 # 1 m above ground. See SIA 380:2015 &3.2.3 p.26-27 sre_plane = Part.Plane(FreeCAD.Vector(0, 0, z_sre), FreeCAD.Vector(0, 0, 1)) space.Area = space.Shape.common(sre_plane.toShape()).Area # TODO: Not valid yet as it return net area. Find a way to get gross space volume space.AreaAE = space.Area def handle_curtain_walls(space, doc) -> None: """Add an hosted window with full area in curtain wall boundaries as they are not handled by BEM softwares""" for boundary in space.SecondLevel.Group: if getattr(boundary.RelatedBuildingElement, "IfcType", "") != "IfcCurtainWall": continue # Prevent Revit issue which produce curtain wall with an hole inside but no inner boundary if not boundary.InnerBoundaries: if len(boundary.Shape.SubShapes) > 2: outer_wire = boundary.Shape.SubShapes[1] utils.generate_boundary_compound(boundary, outer_wire, ()) boundary.LesoType = "Wall" fake_window = doc.copyObject(boundary) fake_window.IsHosted = True fake_window.LesoType = "Window" fake_window.ParentBoundary = boundary fake_window.GlobalId = ifcopenshell.guid.new() fake_window.Id = IfcId.new(doc) RelSpaceBoundary.set_label(fake_window) space.SecondLevel.addObject(fake_window) # Host cannot be an empty face so inner wire is scaled down a little inner_wire = utils.get_outer_wire(boundary).scale(0.999) inner_wire = utils.project_wire_to_plane(inner_wire, utils.get_plane(boundary)) utils.append_inner_wire(boundary, inner_wire) utils.append(boundary, "InnerBoundaries", fake_window) if FreeCAD.GuiUp: fake_window.ViewObject.ShapeColor = (0.0, 0.7, 1.0) class IfcId: """Generate new id for generated boundaries missing from ifc and keep track of last id used""" current_id = 0 @classmethod def new(cls, doc) -> int: if not cls.current_id: cls.current_id = max((getattr(obj, "Id", 0) for obj in doc.Objects)) cls.current_id += 1 return cls.current_id def write_xml(doc=FreeCAD.ActiveDocument) -> BEMxml: """Read BEM infos for FreeCAD file and write it to an xml. xml is stored in an object to allow different outputs""" bem_xml = BEMxml() for project in utils.get_elements_by_ifctype("IfcProject", doc): bem_xml.write_project(project) + for zone in utils.get_elements_by_ifctype("IfcZone", doc): + bem_xml.write_zone(zone) for space in utils.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_type in utils.get_by_class(doc, ElementType): bem_xml.write_building_element_types(building_element_type) for building_element in utils.get_by_class(doc, Element): bem_xml.write_building_elements(building_element) for material in utils.get_by_class( doc, ( materials.Material, materials.ConstituentSet, materials.LayerSet, materials.ProfileSet, ), ): bem_xml.write_material(material) 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 group_by_shared_element(boundaries) -> Dict[str, List["boundary"]]: elements_dict = dict() for rel_boundary in boundaries: try: key = f"{rel_boundary.RelatedBuildingElement.Id}_{rel_boundary.InternalOrExternalBoundary}" except AttributeError: if rel_boundary.PhysicalOrVirtualBoundary == "VIRTUAL": logger.info("IfcElement %s is VIRTUAL. Modeling error ?") key = "VIRTUAL" else: logger.warning( "IfcElement %s has no RelatedBuildingElement", rel_boundary.Id ) corresponding_boundary = rel_boundary.CorrespondingBoundary if corresponding_boundary: key += str(corresponding_boundary.Id) elements_dict.setdefault(key, []).append(rel_boundary) return elements_dict def group_coplanar_boundaries(boundary_list) -> List[List["boundary"]]: coplanar_boundaries = list() for boundary in boundary_list: if not coplanar_boundaries: coplanar_boundaries.append([boundary]) continue for coplanar_list in coplanar_boundaries: if utils.is_coplanar(boundary, coplanar_list[0]): coplanar_list.append(boundary) break else: coplanar_boundaries.append([boundary]) return coplanar_boundaries def merge_over_splitted_boundaries(space, doc=FreeCAD.ActiveDocument): """Try to merge oversplitted boundaries to reduce the number of boundaries and make sure that windows are not splitted as it is often with some authoring softwares like Revit. Why ? Less boundaries is more manageable, closer to what user expect and require less computational power""" boundaries = space.SecondLevel.Group # Considered as the minimal size for an oversplit to occur (1 ceiling, 3 wall, 1 flooring) if len(boundaries) <= 5: return elements_dict = group_by_shared_element(boundaries) # Merge hosted elements first for key, boundary_list in elements_dict.items(): if boundary_list[0].IsHosted and len(boundary_list) != 1: coplanar_groups = group_coplanar_boundaries(boundary_list) for group in coplanar_groups: merge_coplanar_boundaries(group, doc) for key, boundary_list in elements_dict.items(): # None coplanar boundaries should not be connected. # eg. round wall splitted with multiple orientations. # Case1: No oversplitted boundaries try: if boundary_list[0].IsHosted or len(boundary_list) == 1: continue except ReferenceError: continue coplanar_groups = group_coplanar_boundaries(boundary_list) for group in coplanar_groups: # Case 1 : only 1 boundary related to the same element. Cannot group boundaries. if len(group) == 1: continue # Case 2 : more than 1 boundary related to the same element might be grouped. try: merge_coplanar_boundaries(group, doc) except Part.OCCError: logger.warning( f"Cannot join boundaries in space <{space.Id}> with key <{key}>" ) def merged_wires(wire1: Part.Wire, wire2: Part.Wire) -> (Part.Wire, List[Part.Wire]): """Try to merge 2 wires meant using face merging algorithm. 1. Transform wires into faces 2. Merge them 3. Return face outer wire and eventual inner wires""" face1 = Part.Face(wire1) face2 = Part.Face(wire2) fusion = face1.fuse(face2) fusion.sewShape() unifier = Part.ShapeUpgrade.UnifySameDomain(fusion) unifier.build() if len(unifier.shape().SubShapes) == 1: new_face = unifier.shape().SubShapes[0] try: return (new_face.OuterWire, new_face.Wires[1:]) except AttributeError: # Rarely returned shape is a Wire. OCCT bug ? pass return (None, []) def merge_boundaries(boundary1, boundary2) -> bool: """Try to merge 2 boundaries. Retrun True if successfully merged""" wire1 = utils.get_outer_wire(boundary1) wire2 = utils.get_outer_wire(boundary2) new_wire, extra_inner_wires = merged_wires(wire1, wire2) if not new_wire: return False # Update shape if boundary1.IsHosted: utils.remove_inner_wire(boundary1.ParentBoundary, wire1) utils.remove_inner_wire(boundary2.ParentBoundary, wire2) utils.append_inner_wire(boundary1.ParentBoundary, new_wire) else: for inner_boundary in boundary2.InnerBoundaries: utils.append(boundary1, "InnerBoundaries", inner_boundary) inner_boundary.ParentBoundary = boundary1 inner_wires = utils.get_inner_wires(boundary1)[:] inner_wires.extend(utils.get_inner_wires(boundary2)) inner_wires.extend(extra_inner_wires) try: utils.generate_boundary_compound(boundary1, new_wire, inner_wires) except RuntimeError as error: logger.exception(error) return False RelSpaceBoundary.recompute_areas(boundary1) return True def merge_corresponding_boundaries(boundary1, boundary2): if boundary2.CorrespondingBoundary: corresponding_boundary = max( boundary1.CorrespondingBoundary, boundary2.CorrespondingBoundary, key=lambda x: x.Area, ) boundary1.CorrespondingBoundary = corresponding_boundary corresponding_boundary.CorrespondingBoundary = boundary1 def merge_coplanar_boundaries(boundaries: list, doc=FreeCAD.ActiveDocument): """Try to merge coplanar boundaries""" if len(boundaries) == 1: return boundary1 = max(boundaries, key=lambda x: x.Area) # Ensure all boundaries are coplanar plane = utils.get_plane(boundary1) for boundary in boundaries: utils.project_boundary_onto_plane(boundary, plane) boundaries.remove(boundary1) remove_from_doc = list() # Attempt to merge boundaries while True and boundaries: for boundary2 in boundaries: if merge_boundaries(boundary1, boundary2): merge_corresponding_boundaries(boundary1, boundary2) boundaries.remove(boundary2) remove_from_doc.append(boundary2) break else: logger.warning( f"""Unable to merge boundaries RelSpaceBoundary Id <{boundary1.Id}> with boundaries <{", ".join(str(b.Id) for b in boundaries)}>""" ) break # Clean FreeCAD document if join operation was a success for fc_object in remove_from_doc: doc.removeObject(fc_object.Name) def create_fake_host(boundary, space, doc): fake_host = doc.copyObject(boundary) fake_host.IsHosted = False fake_host.LesoType = "Wall" fake_host.GlobalId = ifcopenshell.guid.new() fake_host.Id = IfcId.new(doc) RelSpaceBoundary.set_label(fake_host) space.SecondLevel.addObject(fake_host) inner_wire = utils.get_outer_wire(boundary) outer_wire = inner_wire.scaled(1.001, inner_wire.CenterOfMass) plane = utils.get_plane(boundary) outer_wire = utils.project_wire_to_plane(outer_wire, plane) inner_wire = utils.project_wire_to_plane(inner_wire, plane) utils.generate_boundary_compound(fake_host, outer_wire, [inner_wire]) boundary.ParentBoundary = fake_host fake_building_element = doc.copyObject(boundary.RelatedBuildingElement) fake_building_element.Id = IfcId.new(doc) fake_host.RelatedBuildingElement = fake_building_element utils.append(fake_host, "InnerBoundaries", boundary) if FreeCAD.GuiUp: fake_host.ViewObject.ShapeColor = (0.7, 0.3, 0.0) return fake_host def ensure_hosted_element_are(space, doc): 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 valid_hosts(boundary): """Guess valid hosts""" for boundary2 in space.SecondLevel.Group: if boundary is boundary2 or is_typically_hosted(boundary2.IfcType): continue if not boundary2.Area.Value - boundary.Area.Value >= 0: continue if not utils.are_parallel_boundaries(boundary, boundary2): continue if utils.are_too_far(boundary, boundary2): continue yield boundary2 def find_host(boundary): fallback_solution = None for boundary2 in valid_hosts(boundary): fallback_solution = boundary2 for inner_wire in utils.get_inner_wires(boundary2): if ( not abs(Part.Face(inner_wire).Area - boundary.Area.Value) < TOLERANCE ): continue return boundary2 if not fallback_solution: raise HostNotFound( f"No host found for RelSpaceBoundary Id<{boundary.Id}>" ) logger.warning( f"Using fallback solution to resolve host of RelSpaceBoundary Id<{boundary.Id}>" ) return fallback_solution try: host = find_host(boundary) except HostNotFound as err: host = create_fake_host(boundary, space, doc) logger.exception(err) boundary.IsHosted = True boundary.ParentBoundary = host utils.append(host, "InnerBoundaries", boundary) def ensure_hosted_are_coplanar(space): for boundary in space.SecondLevel.Group: inner_wires = utils.get_inner_wires(boundary) missing_inner_wires = False if len(inner_wires) < len(boundary.InnerBoundaries): missing_inner_wires = True outer_wire = utils.get_outer_wire(boundary) for inner_boundary in boundary.InnerBoundaries: if utils.is_coplanar(inner_boundary, boundary) and not missing_inner_wires: continue utils.project_boundary_onto_plane(inner_boundary, utils.get_plane(boundary)) inner_wire = utils.get_outer_wire(inner_boundary) inner_wires.append(inner_wire) try: utils.generate_boundary_compound(boundary, outer_wire, inner_wires) except RuntimeError: continue def is_typically_hosted(ifc_type: str): """Say if given ifc_type is typically hosted eg. windows, doors""" usually_hosted_types = ("IfcWindow", "IfcDoor", "IfcOpeningElement") for usual_type in usually_hosted_types: if ifc_type.startswith(usual_type): return True return False class HostNotFound(LookupError): pass Closest = namedtuple("Closest", ["boundary", "edge", "distance"]) def init_closest_default_values(boundaries): for boundary in boundaries: n_edges = len(utils.get_outer_wire(boundary).Edges) boundary.Proxy.closest = [ Closest(boundary=None, edge=-1, distance=100000) ] * n_edges def compare_closest_edges(boundary1, ei1, edge1, boundary2, ei2, edge2): distance = boundary1.Proxy.closest[ei1].distance edge_to_edge = edge_distance_to_edge(edge1, edge2) if distance <= TOLERANCE: return elif edge_to_edge <= TOLERANCE or edge_to_edge - distance - TOLERANCE <= 0: boundary1.Proxy.closest[ei1] = Closest(boundary2, ei2, edge_to_edge) def find_closest_by_distance(boundary1, boundary2): edges1 = utils.get_outer_wire(boundary1).Edges edges2 = utils.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_edges(boundary1, ei1, edge1, boundary2, ei2, edge2) compare_closest_edges( # pylint: disable=arguments-out-of-order boundary2, ei2, edge2, boundary1, ei1, edge1 ) def find_closest_by_intersection(boundary1, boundary2): intersect_line = utils.get_plane(boundary1).intersectSS(utils.get_plane(boundary2))[ 0 ] boundaries_distance = boundary1.Shape.distToShape(boundary2.Shape)[0] edges1 = utils.get_outer_wire(boundary1).Edges edges2 = utils.get_outer_wire(boundary2).Edges for (ei1, edge1), (ei2, edge2) in itertools.product( enumerate(edges1), enumerate(edges2) ): distance1 = edge_distance_to_line(edge1, intersect_line) + boundaries_distance distance2 = edge_distance_to_line(edge2, intersect_line) + boundaries_distance min_distance = boundary1.Proxy.closest[ei1].distance if distance1 < min_distance: boundary1.Proxy.closest[ei1] = Closest(boundary2, -1, distance1) min_distance = boundary2.Proxy.closest[ei2].distance if distance2 < min_distance: boundary2.Proxy.closest[ei2] = Closest(boundary1, -1, distance2) def find_closest_edges(space: "SpaceFeature") -> None: """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] init_closest_default_values(boundaries) # Loop through all boundaries and edges to find the closest edge for boundary1, boundary2 in itertools.combinations(boundaries, 2): # If boundary1 and boundary2 have opposite direction no match possible normals_dot = boundary2.Normal.dot(boundary1.Normal) if normals_dot <= -1 + TOLERANCE: continue # If boundaries are not almost parallel, they must intersect if not normals_dot >= 1 - TOLERANCE: find_closest_by_intersection(boundary1, boundary2) # If they are parallel all edges need to be compared else: find_closest_by_distance(boundary1, boundary2) # 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 if b else -1 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: # LesoType is defined in previous steps for curtain walls if boundary.LesoType != "Unknown": continue 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 define LesoType for boundary <{boundary.Id}>") return "Unknown" if ifc_type.startswith("IfcWindow"): return "Window" elif ifc_type.startswith("IfcDoor"): return "Door" elif ifc_type.startswith("IfcWall"): return "Wall" elif ifc_type.startswith("IfcSlab") or ifc_type == "IfcRoof": # Pointing up => Ceiling. Pointing down => Flooring if boundary.Normal.z > 0: return "Ceiling" return "Flooring" elif ifc_type.startswith("IfcOpeningElement"): return "Opening" else: logger.warning(f"Unable to define LesoType for Boundary Id <{boundary.Id}>") return "Unknown" def edge_distance_to_edge(edge1: Part.Edge, edge2: Part.Edge) -> float: mid_point = edge1.CenterOfMass line_segment = (v.Point for v in edge2.Vertexes) return mid_point.distanceToLineSegment(*line_segment).Length def edge_distance_to_line(edge, line): mid_point = edge.CenterOfMass return mid_point.distanceToLine(line.Location, line.Direction) def is_low_angle(edge1, edge2): try: 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.866 ) # Low angle considered as < 30°. cos(pi/6)=0.866. except IndexError: return False def create_sia_boundaries(doc=FreeCAD.ActiveDocument): """Create boundaries necessary for SIA calculations""" for space in utils.get_elements_by_ifctype("IfcSpace", doc): create_sia_ext_boundaries(space) create_sia_int_boundaries(space) rejoin_boundaries(space, "SIA_Exterior") rejoin_boundaries(space, "SIA_Interior") Progress.set() def get_intersecting_line(boundary1, boundary2) -> Optional[Part.Line]: plane_intersect = utils.get_plane(boundary1).intersectSS(utils.get_plane(boundary2)) return plane_intersect[0] if plane_intersect else None def get_medial_axis(boundary1, boundary2, ei1, ei2) -> Optional[Part.Line]: line1 = utils.line_from_edge(utils.get_outer_wire(boundary1).Edges[ei1]) try: line2 = utils.line_from_edge(utils.get_outer_wire(boundary2).Edges[ei2]) except IndexError: logger.warning( f"""Cannot find closest edge index <{ei2}> in boundary <{boundary2.Label}> to rejoin boundary <{boundary1.Label}>""" ) return None # Case 2a : edges are not parallel if abs(line1.Direction.dot(line2.Direction)) < 1 - TOLERANCE: b1_plane = utils.get_plane(boundary1) line_intersect = line1.intersect2d(line2, b1_plane) if line_intersect: point1 = b1_plane.value(*line_intersect[0]) if line1.Direction.dot(line2.Direction) > 0: point2 = point1 + line1.Direction + line2.Direction else: point2 = point1 + line1.Direction - line2.Direction # Case 2b : edges are parallel else: point1 = (line1.Location + line2.Location) * 0.5 point2 = point1 + line1.Direction try: return Part.Line(point1, point2) except Part.OCCError: logger.exception( f"Failure in boundary id <{boundary1.SourceBoundary.Id}> {point1} and {point2} are equal" ) return None def is_valid_join(line, fallback_line): """Angle < 15 ° is considered as valid join. cos(pi/6 ≈ 0.96)""" return abs(line.Direction.dot(fallback_line.Direction)) > 0.96 def rejoin_boundaries(space, sia_type): """ Rejoin boundaries after their translation to get a correct close shell surfaces. 1 Fill gaps between boundaries (2b) 2 Fill gaps gerenate by translation to make a boundary on the inside or outside boundary of building elements https://standards.buildingsmart.org/IFC/RELEASE/IFC4/ADD2_TC1/HTML/schema/ifcproductextension/lexical/ifcrelspaceboundary2ndlevel.htm # pylint: disable=line-too-long """ base_boundaries = space.SecondLevel.Group for base_boundary in base_boundaries: boundary1 = getattr(base_boundary, sia_type) if not boundary1: continue lines = [] fallback_lines = [ utils.line_from_edge(edge) for edge in utils.get_outer_wire(boundary1).Edges ] # bound_box used to make sure line solution is in a reallistic scope (distance <= 5 m) bound_box = boundary1.Shape.BoundBox bound_box.enlarge(5000) if ( base_boundary.IsHosted or base_boundary.PhysicalOrVirtualBoundary == "VIRTUAL" or not base_boundary.RelatedBuildingElement ): continue b1_plane = utils.get_plane(boundary1) for b2_id, (ei1, ei2), fallback_line in zip( base_boundary.ClosestBoundaries, enumerate(base_boundary.ClosestEdges), fallback_lines, ): base_boundary2 = utils.get_in_list_by_id(base_boundaries, b2_id) boundary2 = getattr(base_boundary2, sia_type, None) if not boundary2: logger.warning(f"Cannot find corresponding boundary with id <{b2_id}>") lines.append(fallback_line) continue # Case 1 : boundaries are not parallel line = get_intersecting_line(boundary1, boundary2) if line: if not is_valid_join(line, fallback_line): line = fallback_line if not bound_box.intersect(line.Location, line.Direction): line = fallback_line lines.append(line) continue # Case 2 : boundaries are parallel line = get_medial_axis(boundary1, boundary2, ei1, ei2) if line and is_valid_join(line, fallback_line): lines.append(line) continue lines.append(fallback_line) # Generate new shape try: outer_wire = utils.polygon_from_lines(lines, b1_plane) except (Part.OCCError, utils.ShapeCreationError): logger.exception( f"Invalid geometry while rejoining boundary Id <{base_boundary.Id}>" ) continue try: Part.Face(outer_wire) except Part.OCCError: logger.exception(f"Unable to rejoin boundary Id <{base_boundary.Id}>") continue inner_wires = utils.get_inner_wires(boundary1) try: utils.generate_boundary_compound(boundary1, outer_wire, inner_wires) except RuntimeError as err: logger.exception(err) continue boundary1.Area = area = boundary1.Shape.Area for inner_boundary in base_boundary.InnerBoundaries: area = area + inner_boundary.Shape.Area boundary1.AreaWithHosted = area def create_sia_ext_boundaries(space): """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) if not boundary1.RelatedBuildingElement: continue thickness = boundary1.RelatedBuildingElement.Thickness.Value leso_type = boundary1.LesoType normal = boundary1.Normal # EXTERNAL: there is multiple possible values for external so testing internal is better. if boundary1.InternalOrExternalBoundary != "INTERNAL": distance = thickness # INTERNAL else: if leso_type == "Flooring": distance = 0 elif leso_type == "Ceiling": distance = thickness else: # Walls distance = thickness / 2 bem_boundary.Placement.move(normal * distance + boundary1.TranslationToSpace) def create_sia_int_boundaries(space): """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 bem_boundary = BEMBoundary.create(boundary, "SIA_Interior") sia_group_obj.addObject(bem_boundary) # Bad location in some software like Revit (last check : revit-ifc 21.1.0.0) if not boundary.TranslationToSpace.isEqual(FreeCAD.Vector(), TOLERANCE): bem_boundary.Placement.move(boundary.TranslationToSpace) class XmlResult(NamedTuple): xml: str log: str def generate_bem_xml_from_file(ifc_path: str) -> XmlResult: try: import pyCaller Progress.progress_func = pyCaller.SetProgress except ImportError: pass Progress.set(0, "IfcImport_OpenIfcFile", "") ifc_importer = IfcImporter(ifc_path) ifc_importer.generate_rel_space_boundaries() doc = ifc_importer.doc processing_sia_boundaries(doc) Progress.set(90, "Communicate_Write", "") xml_str = write_xml(doc).tostring() log_str = LOG_STREAM.getvalue() Progress.set(100, "Communicate_Send", "") return XmlResult(xml_str, log_str) def process_test_file(ifc_path, doc): ifc_importer = IfcImporter(ifc_path, doc) ifc_importer.generate_rel_space_boundaries() processing_sia_boundaries(doc) bem_xml = write_xml(doc) output_xml_to_path(bem_xml) ifc_importer.xml = bem_xml ifc_importer.log = LOG_STREAM.getvalue() if FreeCAD.GuiUp: FreeCADGui.activeView().viewIsometric() FreeCADGui.SendMsgToActiveView("ViewFit") with open("./boundaries.log", "w", encoding="utf-8") as log_file: log_file.write(ifc_importer.log) return ifc_importer class InvalidMergeError(RuntimeError): pass diff --git a/freecad/bem/entities.py b/freecad/bem/entities.py index bd19626..60970a6 100644 --- a/freecad/bem/entities.py +++ b/freecad/bem/entities.py @@ -1,519 +1,548 @@ # coding: utf8 """This module contains FreeCAD wrapper class for IfcEntities © 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 typing from typing import Iterable import ifcopenshell import FreeCAD import Part from freecad.bem.bem_logging import logger from freecad.bem import utils if typing.TYPE_CHECKING: # https://www.python.org/dev/peps/pep-0484/#id36 from freecad.bem.ifc_importer import IfcImporter from freecad.bem.typing import ( # pylint: disable=import-error, no-name-in-module RootFeature, BEMBoundaryFeature, RelSpaceBoundaryFeature, ContainerFeature, SpaceFeature, ProjectFeature, + ZoneFeature, ElementFeature, ) 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 if not ifc_product: return (1.0, 0.0, 0.0) 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 print(f"No color found for {ifc_product.is_a()}") return (0.0, 0.0, 0.0) def get_related_element(ifc_entity, doc=FreeCAD.ActiveDocument) -> Part.Feature: 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 class Root: """Wrapping various IFC entity : https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/link/ifcroot.htm """ def __init__(self, obj: "RootFeature") -> None: self.Type = self.__class__.__name__ # pylint: disable=invalid-name obj.Proxy = self # pylint: disable=invalid-name # TODO: remove 2nd argument for FreeCAD ⩾0.19.1. See https://forum.freecadweb.org/viewtopic.php?t=57479 obj.addExtension("App::GroupExtensionPython", self) @classmethod def _init_properties(cls, obj: "RootFeature") -> None: 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) @classmethod def create(cls) -> "RootFeature": """Stantard FreeCAD FeaturePython Object creation method""" obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", cls.__name__) cls(obj) cls._init_properties(obj) if FreeCAD.GuiUp: obj.ViewObject.Proxy = ViewProviderRoot(obj.ViewObject) return obj @classmethod def create_from_ifc(cls, ifc_entity, ifc_importer: "IfcImporter") -> "RootFeature": """As cls.create but providing an ifc source""" obj = cls.create() obj.Proxy.ifc_importer = ifc_importer cls.read_from_ifc(obj, ifc_entity) cls.set_label(obj) return obj @classmethod def read_from_ifc(cls, obj: "RootFeature", ifc_entity) -> None: obj.Id = ifc_entity.id() obj.GlobalId = ifc_entity.GlobalId obj.IfcType = ifc_entity.is_a() obj.IfcName = ifc_entity.Name or "" obj.Description = ifc_entity.Description or "" @staticmethod def set_label(obj: "RootFeature") -> None: """Allow specific method for specific elements""" obj.Label = f"{obj.Id}_{obj.IfcName or obj.IfcType}" @staticmethod def read_pset_from_ifc( obj: "RootFeature", ifc_entity, properties: Iterable[str] ) -> None: psets = ifcopenshell.util.element.get_psets(ifc_entity) for pset in psets.values(): for prop_name, prop in pset.items(): if prop_name in properties: setattr(obj, prop_name, getattr(prop, "wrappedValue", prop)) 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: "RelSpaceBoundaryFeature") -> None: super().__init__(obj) obj.Proxy = self @classmethod def _init_properties(cls, obj: "RelSpaceBoundaryFeature") -> None: super()._init_properties(obj) bem_category = "BEM" ifc_attributes = "IFC Attributes" obj.addProperty("App::PropertyLinkHidden", "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::PropertyLinkHidden", "CorrespondingBoundary", ifc_attributes ) obj.addProperty("App::PropertyLinkHidden", "ParentBoundary", ifc_attributes) obj.addProperty("App::PropertyLinkList", "InnerBoundaries", ifc_attributes) obj.addProperty("App::PropertyVector", "Normal", bem_category) obj.addProperty("App::PropertyVector", "TranslationToSpace", bem_category) obj.addProperty("App::PropertyLength", "UndergroundDepth", 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::PropertyBool", "IsHosted", bem_category) obj.addProperty( "App::PropertyInteger", "InternalToExternal", bem_category, """Define if material layers direction. 1 mean from inside of the space toward outside of the space. -1 is the opposite. 0 is undefined (bool not used as 3 state bool do no exist in FreeCAD yet).""", ) 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", "Wall", "Flooring", "Window", "Door", "Opening", "Unknown", ] @classmethod def read_from_ifc(cls, obj: "RelSpaceBoundaryFeature", ifc_entity) -> None: super().read_from_ifc(obj, ifc_entity) ifc_importer = obj.Proxy.ifc_importer element = get_related_element(ifc_entity, ifc_importer.doc) if element: obj.RelatedBuildingElement = element utils.append(element, "ProvidesBoundaries", obj) obj.InternalOrExternalBoundary = ifc_entity.InternalOrExternalBoundary obj.PhysicalOrVirtualBoundary = ifc_entity.PhysicalOrVirtualBoundary try: obj.Shape = ifc_importer.create_fc_shape(ifc_entity) except utils.ShapeCreationError: ifc_importer.doc.removeObject(obj.Name) raise utils.ShapeCreationError obj.Area = obj.AreaWithHosted = obj.Shape.Area if obj.Area < utils.TOLERANCE: ifc_importer.doc.removeObject(obj.Name) raise utils.IsTooSmall try: obj.IsHosted = bool(ifc_entity.RelatedBuildingElement.FillsVoids) except AttributeError: obj.IsHosted = False obj.LesoType = "Unknown" obj.CorrespondingBoundary = getattr(ifc_entity, "CorrespondingBoundary", None) if FreeCAD.GuiUp: obj.ViewObject.Proxy = 0 obj.ViewObject.ShapeColor = get_color(ifc_entity) def onChanged( self, obj: "RelSpaceBoundaryFeature", prop ): # pylint: disable=invalid-name if prop == "InnerBoundaries": self.recompute_area_with_hosted(obj) @classmethod def recompute_areas(cls, obj: "RelSpaceBoundaryFeature") -> None: obj.Area = obj.Shape.Faces[0].Area cls.recompute_area_with_hosted(obj) @staticmethod def recompute_area_with_hosted(obj: "RelSpaceBoundaryFeature") -> None: """Recompute area including inner boundaries""" area = obj.Area for boundary in obj.InnerBoundaries: area = area + boundary.Area obj.AreaWithHosted = area @classmethod def set_label(cls, obj: "RelSpaceBoundaryFeature") -> None: try: obj.Label = f"{obj.Id}_{obj.RelatedBuildingElement.IfcName}" except AttributeError: obj.Label = f"{obj.Id} VIRTUAL" if obj.PhysicalOrVirtualBoundary != "VIRTUAL": logger.warning( f"{obj.Id} is not VIRTUAL and has no RelatedBuildingElement" ) @staticmethod def get_wires(obj): return utils.get_wires(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: "ElementFeature") -> None: super().__init__(obj) self.Type = "IfcElement" obj.Proxy = self @classmethod def create_from_ifc( cls, ifc_entity, ifc_importer: "IfcImporter" ) -> "ElementFeature": """Stantard FreeCAD FeaturePython Object creation method""" obj = super().create_from_ifc(ifc_entity, ifc_importer) ifc_importer.create_element_type( obj, ifcopenshell.util.element.get_type(ifc_entity) ) ifc_importer.material_creator.create(obj, ifc_entity) obj.Thickness = ifc_importer.guess_thickness(obj, ifc_entity) if FreeCAD.GuiUp: obj.ViewObject.Proxy = 0 return obj @classmethod def _init_properties(cls, obj: "ElementFeature") -> None: super()._init_properties(obj) ifc_attributes = "IFC Attributes" bem_category = "BEM" obj.addProperty("App::PropertyLink", "Material", ifc_attributes) obj.addProperty( "App::PropertyLinkListHidden", "ProvidesBoundaries", ifc_attributes ) obj.addProperty("App::PropertyLinkHidden", "IsTypedBy", ifc_attributes) obj.addProperty("App::PropertyFloat", "ThermalTransmittance", ifc_attributes) obj.addProperty("App::PropertyLinkList", "HostedElements", bem_category) obj.addProperty("App::PropertyLinkListHidden", "HostElements", bem_category) obj.addProperty("App::PropertyLength", "Thickness", bem_category) @classmethod def read_from_ifc(cls, obj, ifc_entity): super().read_from_ifc(obj, ifc_entity) obj.Label = f"{obj.Id}_{obj.IfcType}" super().read_pset_from_ifc( obj, ifc_entity, [ "ThermalTransmittance", ], ) class ElementType(Root): """Wrapping various IFC entity : https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/schema/ifcproductextension/lexical/ifcelement.htm """ def __init__(self, obj: "ElementFeature") -> None: super().__init__(obj) self.Type = "IfcElementType" obj.Proxy = self @classmethod def create_from_ifc( cls, ifc_entity, ifc_importer: "IfcImporter" ) -> "ElementFeature": """Stantard FreeCAD FeaturePython Object creation method""" obj = super().create_from_ifc(ifc_entity, ifc_importer) ifc_importer.material_creator.create(obj, ifc_entity) obj.Thickness = ifc_importer.guess_thickness(obj, ifc_entity) if FreeCAD.GuiUp: obj.ViewObject.Proxy = 0 return obj @classmethod def _init_properties(cls, obj: "ElementFeature") -> None: super()._init_properties(obj) ifc_attributes = "IFC Attributes" bem_category = "BEM" obj.addProperty("App::PropertyLink", "Material", ifc_attributes) obj.addProperty("App::PropertyFloat", "ThermalTransmittance", ifc_attributes) obj.addProperty("App::PropertyLinkList", "ApplicableOccurrence", ifc_attributes) obj.addProperty("App::PropertyLength", "Thickness", bem_category) @classmethod def read_from_ifc(cls, obj, ifc_entity): super().read_from_ifc(obj, ifc_entity) obj.Label = f"{obj.Id}_{obj.IfcType}" super().read_pset_from_ifc( obj, ifc_entity, [ "ThermalTransmittance", ], ) class BEMBoundary: def __init__( self, obj: "BEMBoundaryFeature", boundary: "RelSpaceBoundaryFeature" ) -> None: self.Type = "BEMBoundary" # pylint: disable=invalid-name obj.Proxy = self category_name = "BEM" obj.addProperty("App::PropertyLinkHidden", "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() self.set_label(obj, boundary) obj.Area = boundary.Area obj.AreaWithHosted = boundary.AreaWithHosted @staticmethod def create(boundary: "RelSpaceBoundaryFeature", geo_type) -> "BEMBoundaryFeature": """Stantard FreeCAD FeaturePython Object creation method""" obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython", "BEMBoundary") BEMBoundary(obj, boundary) setattr(boundary, geo_type, obj) if FreeCAD.GuiUp: # ViewProviderRelSpaceBoundary(obj.ViewObject) obj.ViewObject.Proxy = 0 obj.ViewObject.ShapeColor = boundary.ViewObject.ShapeColor return obj @staticmethod def set_label(obj: "RelSpaceBoundaryFeature", source_boundary) -> None: obj.Label = source_boundary.Label @staticmethod def get_wires(obj): return utils.get_wires(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): super().__init__(obj) obj.Proxy = self @classmethod def create_from_ifc(cls, ifc_entity, ifc_importer: "IfcImporter"): obj = super().create_from_ifc(ifc_entity, ifc_importer) cls.set_label(obj) if FreeCAD.GuiUp: 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""" @classmethod def create(cls) -> "ProjectFeature": obj = super().create() if FreeCAD.GuiUp: obj.ViewObject.DisplayMode = "Wireframe" return obj @classmethod def _init_properties(cls, obj: "ProjectFeature") -> None: super()._init_properties(obj) 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) owning_application = "OwningApplication" obj.addProperty( "App::PropertyString", "ApplicationIdentifier", owning_application ) obj.addProperty("App::PropertyString", "ApplicationVersion", owning_application) obj.addProperty( "App::PropertyString", "ApplicationFullName", owning_application ) @classmethod def read_from_ifc(cls, obj: "ProjectFeature", ifc_entity) -> None: super().read_from_ifc(obj, ifc_entity) obj.LongName = ifc_entity.LongName or "" true_north = ifc_entity.RepresentationContexts[0].TrueNorth obj.TrueNorth = ( FreeCAD.Vector(*true_north.DirectionRatios) if true_north else FreeCAD.Vector(0, 1) ) obj.WorldCoordinateSystem = FreeCAD.Vector( ifc_entity.RepresentationContexts[ 0 ].WorldCoordinateSystem.Location.Coordinates ) owning_application = ifc_entity.OwnerHistory.OwningApplication obj.ApplicationIdentifier = owning_application.ApplicationIdentifier obj.ApplicationVersion = owning_application.Version obj.ApplicationFullName = owning_application.ApplicationFullName @classmethod def set_label(cls, obj): obj.Label = f"{obj.IfcName}_{obj.LongName}" class Space(Root): """Representation of an IfcProject: https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/link/ifcproject.htm""" @classmethod def create(cls) -> "SpaceFeature": obj = super().create() if FreeCAD.GuiUp: obj.ViewObject.ShapeColor = (0.33, 1.0, 1.0) obj.ViewObject.Transparency = 90 return obj @classmethod def _init_properties(cls, obj: "SpaceFeature") -> None: super()._init_properties(obj) ifc_attributes = "IFC Attributes" obj.addProperty("App::PropertyString", "LongName", ifc_attributes) 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) bem_category = "BEM" obj.addProperty("App::PropertyArea", "Area", bem_category) obj.addProperty("App::PropertyArea", "AreaAE", bem_category) @classmethod def read_from_ifc(cls, obj: "SpaceFeature", ifc_entity) -> None: super().read_from_ifc(obj, ifc_entity) ifc_importer = obj.Proxy.ifc_importer obj.Shape = ifc_importer.space_shape_by_brep(ifc_entity) obj.LongName = ifc_entity.LongName or "" space_full_name = f"{ifc_entity.Name} {ifc_entity.LongName}" obj.Label = space_full_name obj.Description = ifc_entity.Description or "" @classmethod def set_label(cls, obj: "SpaceFeature") -> None: obj.Label = f"{obj.IfcName}_{obj.LongName}" + + +class Zone(Root): + """Representation of an IfcZone: + http://ifc43-docs.standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/lexical/IfcZone.htm""" + + @classmethod + def create(cls) -> "ZoneFeature": + obj = super().create() + if FreeCAD.GuiUp: + obj.ViewObject.DisplayMode = "Wireframe" + return obj + + @classmethod + def _init_properties(cls, obj: "ZoneFeature") -> None: + super()._init_properties(obj) + ifc_attributes = "IFC Attributes" + obj.addProperty("App::PropertyString", "LongName", ifc_attributes) + obj.addProperty("App::PropertyLinkList", "RelatedObjects", ifc_attributes) + + @classmethod + def read_from_ifc(cls, obj: "ZoneFeature", ifc_entity) -> None: + super().read_from_ifc(obj, ifc_entity) + obj.LongName = ifc_entity.LongName or "" + + @classmethod + def set_label(cls, obj): + obj.Label = f"{obj.IfcName}_{obj.LongName}" diff --git a/freecad/bem/ifc_importer.py b/freecad/bem/ifc_importer.py index 6334082..c5697d1 100644 --- a/freecad/bem/ifc_importer.py +++ b/freecad/bem/ifc_importer.py @@ -1,720 +1,730 @@ # coding: utf8 """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 """ from typing import NamedTuple, Generator import os import zipfile import ifcopenshell import ifcopenshell.geom import ifcopenshell.util.element import ifcopenshell.util.unit import FreeCAD import Part from freecad.bem import materials from freecad.bem import utils from freecad.bem.bem_logging import logger from freecad.bem.progress import Progress from freecad.bem.entities import ( RelSpaceBoundary, Element, ElementType, Container, Space, Project, + Zone, ) 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 def get_by_class(doc=FreeCAD.ActiveDocument, by_class=object): """Generator throught FreeCAD document element of specific python proxy class""" for element in doc.Objects: try: if isinstance(element.Proxy, by_class): yield element except AttributeError: continue def get_elements_by_ifctype( ifc_type: str, doc=FreeCAD.ActiveDocument ) -> Generator[Part.Feature, None, None]: """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 get_materials(doc=FreeCAD.ActiveDocument): """Generator throught FreeCAD document element of specific python proxy class""" for element in doc.Objects: try: if element.IfcType in ( "IfcMaterial", "IfcMaterialList", "IfcMaterialLayerSet", "IfcMaterialLayerSetUsage", "IfcMaterialConstituentSet", "IfcMaterialConstituent", ): yield element except AttributeError: continue def get_unit_conversion_factor(ifc_file, unit_type, default=None): # TODO: Test with Imperial units units = [ u for u in ifc_file.by_type("IfcUnitAssignment")[0][0] if getattr(u, "UnitType", None) == unit_type ] if len(units) == 0: return default ifc_unit = units[0] unit_factor = 1.0 if ifc_unit.is_a("IfcConversionBasedUnit"): ifc_unit = ifc_unit.ConversionFactor unit_factor = ifc_unit.wrappedValue assert ifc_unit.is_a("IfcSIUnit") prefix_factor = ifcopenshell.util.unit.get_prefix_multiplier(ifc_unit.Prefix) return unit_factor * prefix_factor def is_second_level(boundary): return boundary.Name.lower() == "2ndlevel" or boundary.is_a( "IfcRelSpaceBoundary2ndLevel" ) class IfcImporter: def __init__(self, ifc_path, doc=None): if not doc: doc = FreeCAD.newDocument() self.doc = doc self.ifc_file = self.open(ifc_path) self.ifc_scale = get_unit_conversion_factor(self.ifc_file, "LENGTHUNIT") self.fc_scale = FreeCAD.Units.Metre.Value self.element_types = dict() self.material_creator = materials.MaterialCreator(self) self.xml: str = "" self.log: str = "" @staticmethod def open(ifc_path: str) -> ifcopenshell.file: ext = os.path.splitext(ifc_path)[1].lower() if ext == ".ifc": return ifcopenshell.open(ifc_path) if ext == ".ifcxml": # TODO: How to do this as ifcopenshell.ifcopenshell_wrapper has no parse_ifcxml ? raise NotImplementedError("No support for .ifcXML yet") if ext in (".ifczip", ".zip"): try: # python 3.8+ zip_path = zipfile.Path(ifc_path) for member in zip_path.iterdir(): zipped_ext = os.path.splitext(member.name)[1].lower() if zipped_ext == ".ifc": return ifcopenshell.file.from_string(member.read_text()) if zipped_ext == ".ifcxml": raise NotImplementedError("No support for .ifcXML yet") except AttributeError as python36_zip_error: # python 3.6 with zipfile.ZipFile(ifc_path) as zip_file: for member in zip_file.namelist(): zipped_ext = os.path.splitext(member)[1].lower() if zipped_ext == ".ifc": with zip_file.open(member) as ifc_file: return ifcopenshell.file.from_string( ifc_file.read().decode() ) if zipped_ext == ".ifcxml": raise NotImplementedError( "No support for .ifcXML yet" ) from python36_zip_error raise NotImplementedError( """Supported files : - unzipped : *.ifc | *.ifcXML - zipped : *.ifczip | *.zip containing un unzipped type""" ) def generate_rel_space_boundaries(self): """Display IfcRelSpaceBoundaries from selected IFC file into FreeCAD documennt""" ifc_file = self.ifc_file doc = self.doc # Generate elements (Door, Window, Wall, Slab etc…) without their geometry Progress.set(1, "IfcImport_Elements", "") elements_group = get_or_create_group("Elements", doc) ifc_elements = ifc_file.by_type("IfcElement") for ifc_entity in ifc_elements: elements_group.addObject(Element.create_from_ifc(ifc_entity, self)) element_types_group = get_or_create_group("ElementTypes", doc) for element_type in utils.get_by_class(doc, ElementType): element_types_group.addObject(element_type) materials_group = get_or_create_group("Materials", doc) for material in get_materials(doc): materials_group.addObject(material) # Generate projects structure and boundaries Progress.set(5, "IfcImport_StructureAndBoundaries", "") for ifc_project in ifc_file.by_type("IfcProject"): project = Project.create_from_ifc(ifc_project, self) self.generate_containers(ifc_project, project) + # Generate zones (IfcZone) + for ifc_zone in ifc_file.by_type("IfcZone"): + zone = Zone.create_from_ifc(ifc_zone, self) + spaces = utils.get_elements_by_ifctype("IfcSpace", doc) + related_objects = [ + utils.get_by_id(space.id(), spaces) + for space in ifc_zone.IsGroupedBy[0].RelatedObjects + ] + zone.RelatedObjects = related_objects # Associate existing ParentBoundary and CorrespondingBoundary associate_parent_and_corresponding(ifc_file, doc) Progress.set(15, "IfcImporter_EnrichingDatas", "") # Associate CorrespondingBoundary associate_corresponding_boundaries(doc) # Associate Host / Hosted elements associate_host_element(ifc_file, elements_group) # Associate hosted elements i = 0 for i, fc_space in enumerate(get_elements_by_ifctype("IfcSpace", doc), 1): Progress.set(15, "IfcImporter_EnrichingDatas", f"{i}") 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) Progress.len_spaces = i def guess_thickness(self, obj, ifc_entity): if obj.Material: thickness = getattr(obj.Material, "TotalThickness", 0) if thickness: return thickness if ifc_entity.is_a("IfcWall"): qto_lookup_name = "Qto_WallBaseQuantities" elif ifc_entity.is_a("IfcSlab"): qto_lookup_name = "Qto_SlabBaseQuantities" else: qto_lookup_name = "" if qto_lookup_name: for definition in ifc_entity.IsDefinedBy: if not definition.is_a("IfcRelDefinesByProperties"): continue if definition.RelatingPropertyDefinition.Name == qto_lookup_name: for quantity in definition.RelatingPropertyDefinition.Quantities: if quantity.Name == "Width": return quantity.LengthValue * self.fc_scale * self.ifc_scale if not getattr(ifc_entity, "Representation", None): return 0 if ifc_entity.IsDecomposedBy: thicknesses = [] for aggregate in ifc_entity.IsDecomposedBy: thickness = 0 for related in aggregate.RelatedObjects: thickness += self.guess_thickness(obj, related) thicknesses.append(thickness) return max(thicknesses) for representation in ifc_entity.Representation.Representations: if ( representation.RepresentationIdentifier == "Box" and representation.RepresentationType == "BoundingBox" ): if self.is_wall_like(obj.IfcType): return representation.Items[0].YDim * self.fc_scale * self.ifc_scale elif self.is_slab_like(obj.IfcType): return representation.Items[0].ZDim * self.fc_scale * self.ifc_scale else: return 0 try: fc_shape = self.element_local_shape_by_brep(ifc_entity) bbox = fc_shape.BoundBox except RuntimeError: return 0 # Returning bbox thickness for windows or doors is not insteresting # as it does not return frame thickness. if self.is_wall_like(obj.IfcType): return min(bbox.YLength, bbox.XLength) elif self.is_slab_like(obj.IfcType): return bbox.ZLength # Here we consider that thickness is distance between the 2 faces with higher area elif ifc_entity.is_a("IfcRoof"): faces = sorted(fc_shape.Faces, key=lambda x: x.Area) if len(faces) < 2: logger.warning( f"""{ifc_entity.is_a()}<{ifc_entity.id()}> has an invalid geometry (empty or less than 2 faces)""" ) return 0 return faces[-1].distToShape(faces[-2])[0] return 0 @staticmethod def is_wall_like(ifc_type): return ifc_type in ("IfcWall", "IfcWallStandardCase", "IfcCurtainWall") @staticmethod def is_slab_like(ifc_type): return ifc_type in ("IfcSlab", "IfcSlabStandardCase") def generate_containers(self, ifc_parent, fc_parent): for rel_aggregates in ifc_parent.IsDecomposedBy: for element in rel_aggregates.RelatedObjects: if element.is_a("IfcSpace"): if element.BoundedBy: self.generate_space(element, fc_parent) else: if element.is_a("IfcSite"): self.workaround_site_coordinates(element) fc_container = Container.create_from_ifc(element, self) fc_parent.addObject(fc_container) self.generate_containers(element, fc_container) def workaround_site_coordinates(self, ifc_site): """Multiple softwares (eg. Revit) are storing World Coordinate system in IfcSite location instead of using IfcProject IfcGeometricRepresentationContext. This is a bad practice should be solved over time""" ifc_location = ifc_site.ObjectPlacement.RelativePlacement.Location fc_location = FreeCAD.Vector(ifc_location.Coordinates) fc_location.scale(*[self.ifc_scale * self.fc_scale] * 3) if not fc_location.Length > 1000000: # 1 km return for project in get_by_class(self.doc, Project): project.WorldCoordinateSystem += fc_location ifc_location.Coordinates = ( 0.0, 0.0, 0.0, ) def generate_space(self, ifc_space, parent): """Generate Space and RelSpaceBoundaries as defined in ifc_file. No post process.""" fc_space = Space.create_from_ifc(ifc_space, self) parent.addObject(fc_space) boundaries = fc_space.newObject("App::DocumentObjectGroup", "Boundaries") fc_space.Boundaries = boundaries second_levels = boundaries.newObject("App::DocumentObjectGroup", "SecondLevel") fc_space.SecondLevel = second_levels # All boundaries have their placement relative to space placement space_placement = self.get_placement(ifc_space) for ifc_boundary in (b for b in ifc_space.BoundedBy if is_second_level(b)): if not ifc_boundary.ConnectionGeometry: logger.warning( f"Boundary <{ifc_boundary.id()}> has no ConnectionGeometry and has therefore been ignored" ) continue try: fc_boundary = RelSpaceBoundary.create_from_ifc( ifc_entity=ifc_boundary, ifc_importer=self ) fc_boundary.RelatingSpace = fc_space second_levels.addObject(fc_boundary) fc_boundary.Placement = space_placement except utils.ShapeCreationError: logger.warning( f"Failed to create fc_shape for RelSpaceBoundary <{ifc_boundary.id()}> even with fallback methode _part_by_mesh. IfcOpenShell bug ?" ) except utils.IsTooSmall: logger.warning( f"Boundary <{ifc_boundary.id()}> shape is too small and has been ignored" ) def get_placement(self, 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] *= self.fc_scale m_l.extend(line) return FreeCAD.Matrix(*m_l) def get_matrix(self, position): """Transform position to FreeCAD.Matrix""" total_scale = self.fc_scale * self.ifc_scale location = FreeCAD.Vector(position.Location.Coordinates) location.scale(*list(3 * [total_scale])) v_1 = FreeCAD.Vector( getattr(position.RefDirection, "DirectionRatios", (1, 0, 0)) ) v_3 = FreeCAD.Vector(getattr(position.Axis, "DirectionRatios", (0, 0, 1))) 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_global_placement(self, obj_placement) -> FreeCAD.Matrix: if not obj_placement: return FreeCAD.Matrix() if not obj_placement.PlacementRelTo: parent = FreeCAD.Matrix() else: parent = self.get_global_placement(obj_placement.PlacementRelTo) return parent.multiply(self.get_matrix(obj_placement.RelativePlacement)) def get_global_y_axis(self, ifc_entity): global_placement = self.get_global_placement(ifc_entity) return FreeCAD.Vector(global_placement.A[1:12:4]) def create_fc_shape(self, ifc_boundary): """Create Part shape from ifc geometry""" if BREP: try: return self._boundary_shape_by_brep( ifc_boundary.ConnectionGeometry.SurfaceOnRelatingElement ) except RuntimeError: print(f"Failed to generate brep from {ifc_boundary}") fallback = True if not BREP or fallback: try: return self.part_by_wires( ifc_boundary.ConnectionGeometry.SurfaceOnRelatingElement ) except (RuntimeError, utils.ShapeCreationError): print(f"Failed to generate mesh from {ifc_boundary}") try: return self._part_by_mesh( ifc_boundary.ConnectionGeometry.SurfaceOnRelatingElement ) except RuntimeError: raise utils.ShapeCreationError def part_by_wires(self, ifc_entity): """Create a Part Shape from ifc geometry""" inner_wires = list() outer_wire = self._polygon_by_mesh(ifc_entity.OuterBoundary) face = Part.Face(outer_wire) try: inner_boundaries = ifc_entity.InnerBoundaries or tuple() for inner_boundary in inner_boundaries: inner_wire = self._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 = self.get_matrix(ifc_entity.BasisSurface.Position) fc_shape = fc_shape.transformGeometry(matrix) return fc_shape def _boundary_shape_by_brep(self, 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.geometry.brep_data) fc_shape.scale(self.fc_scale) return fc_shape def element_local_shape_by_brep(self, ifc_entity) -> Part.Shape: """Create a Element Shape from brep generated by ifcopenshell from ifc geometry""" settings = ifcopenshell.geom.settings() settings.set(settings.USE_BREP_DATA, True) settings.set(settings.USE_WORLD_COORDS, False) ifc_shape = ifcopenshell.geom.create_shape(settings, ifc_entity) fc_shape = Part.Shape() fc_shape.importBrepFromString(ifc_shape.geometry.brep_data) fc_shape.scale(self.fc_scale) return fc_shape def space_shape_by_brep(self, ifc_entity) -> Part.Shape: """Create a Space Shape from brep generated by ifcopenshell from ifc geometry""" settings = ifcopenshell.geom.settings() settings.set(settings.USE_BREP_DATA, True) settings.set(settings.USE_WORLD_COORDS, True) ifc_shape = ifcopenshell.geom.create_shape(settings, ifc_entity) fc_shape = Part.Shape() fc_shape.importBrepFromString(ifc_shape.geometry.brep_data) fc_shape.scale(self.fc_scale) return fc_shape def _part_by_mesh(self, ifc_entity): """Create a Part Shape from mesh generated by ifcopenshell from ifc geometry""" return Part.Face(self._polygon_by_mesh(ifc_entity)) def _polygon_by_mesh(self, 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(*[self.fc_scale] * 3) for i in range(0, len(ifc_verts), 3) ] utils.clean_vectors(fc_verts) if len(fc_verts) < 3: raise utils.ShapeCreationError utils.close_vectors(fc_verts) return Part.makePolygon(fc_verts) def create_element_type(self, fc_element, ifc_entity_type): if not ifc_entity_type: return try: fc_element_type = self.element_types[ifc_entity_type.id()] except KeyError: fc_element_type = ElementType.create_from_ifc(ifc_entity_type, self) self.element_types[fc_element_type.Id] = fc_element_type fc_element.IsTypedBy = fc_element_type utils.append(fc_element_type, "ApplicableOccurrence", fc_element) 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: try: host = utils.get_element_by_guid( utils.get_host_guid(ifc_entity), elements_group ) except LookupError as err: logger.exception(err) continue hosted = utils.get_element_by_guid(ifc_entity.GlobalId, elements_group) utils.append(host, "HostedElements", hosted) utils.append(hosted, "HostElements", host) def get_host(boundary, hosts): if not hosts: # Common issue with both ArchiCAD and Revit logger.debug(f"Boundary <{boundary.Label}> is hosted but host not found.") return None if len(hosts) == 1: host = hosts.pop() if utils.are_parallel_boundaries(boundary, host): return host else: for host in hosts: if not utils.are_parallel_boundaries(boundary, host): continue if utils.are_too_far(boundary, host): continue return host raise InvalidBoundary( f""" boundary {boundary.Label} is hosted and his related building element fills an element void. Boundary is invalid. This occur with Revit models. Invalid boundary is created when a window is touching another wall which is not his host """ ) def remove_invalid_inner_wire(boundary, boundaries): """Find and remove inner wire produce by invalid hosted boundary""" area = boundary.Area.Value for host in boundaries: if not utils.are_parallel_boundaries(boundary, host): continue if utils.are_too_far(boundary, host): continue inner_wires = utils.get_inner_wires(host) for wire in inner_wires: if abs(Part.Face(wire).Area - area) < TOLERANCE: utils.remove_inner_wire(host, wire) utils.update_boundary_shape(host) return def associate_inner_boundaries(fc_boundaries, doc): """Associate parent boundary and inner boundaries""" to_delete = [] for fc_boundary in fc_boundaries: if not fc_boundary.IsHosted or fc_boundary.ParentBoundary: continue host_boundaries = [] for host_element in fc_boundary.RelatedBuildingElement.HostElements: host_boundaries.extend(host_element.ProvidesBoundaries) candidates = set(fc_boundaries).intersection(host_boundaries) try: host = get_host(fc_boundary, candidates) except InvalidBoundary as err: logger.exception(err) to_delete.append(fc_boundary) continue fc_boundary.ParentBoundary = host if host: utils.append(host, "InnerBoundaries", fc_boundary) # Remove invalid boundary and corresponding inner wire updated_boundaries = fc_boundaries[:] for boundary in to_delete: remove_invalid_inner_wire(boundary, updated_boundaries) updated_boundaries.remove(boundary) doc.removeObject(boundary.Name) def associate_parent_and_corresponding(ifc_file, doc): try: for boundary in ifc_file.by_type("IfcRelSpaceBoundary2ndLevel"): if boundary.ParentBoundary: fc_boundary = utils.get_object(boundary, doc) fc_parent = utils.get_object(boundary.ParentBoundary, doc) fc_boundary.ParentBoundary = fc_parent utils.append(fc_parent, "InnerBoundaries", fc_boundary) if boundary.CorrespondingBoundary: fc_boundary = utils.get_object(boundary, doc) if fc_boundary.CorrespondingBoundary: continue fc_corresponding_boundary = utils.get_object( boundary.CorrespondingBoundary, doc ) fc_boundary.CorrespondingBoundary = fc_corresponding_boundary fc_corresponding_boundary.CorrespondingBoundary = fc_boundary except RuntimeError: # When entity do not exist in the schema pass 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 cleaned_corresponding_candidates(boundary1): candidates = [] for boundary2 in getattr( boundary1.RelatedBuildingElement, "ProvidesBoundaries", () ): if boundary2 is boundary1: continue if boundary2.CorrespondingBoundary: continue if boundary2.InternalOrExternalBoundary != "INTERNAL": continue if boundary1.RelatingSpace is boundary2.RelatingSpace: continue if not abs(1 - boundary1.Area.Value / boundary2.Area.Value) < TOLERANCE: continue candidates.append(boundary2) return candidates def get_best_corresponding_candidate(boundary, candidates): if len(candidates) == 1: return candidates[0] corresponding_boundary = None center_of_mass = utils.get_outer_wire(boundary).CenterOfMass min_lenght = 10000 # No element has 10 m thickness for candidate in candidates: distance = center_of_mass.distanceToPoint( utils.get_outer_wire(candidate).CenterOfMass ) if distance < min_lenght: min_lenght = distance corresponding_boundary = candidate return corresponding_boundary def seems_too_smal(boundary) -> bool: """considered as too small if width or heigth < 100 mm""" try: uv_nodes = boundary.Shape.Faces[0].getUVNodes() return min(abs(n_2 - n_1) for n_1, n_2 in zip(uv_nodes[0], uv_nodes[2])) < 100 except RuntimeError: # TODO: further investigation to see why it happens if boundary.Shape.Faces[0].Area < 10000: # 0.01 m² return True else: return False def associate_corresponding_boundary(boundary, doc): """Associate corresponding boundaries according to IFC definition. Reference to the other space boundary of the pair of two space boundaries on either side of a space separating thermal boundary element. https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/link/ifcrelspaceboundary2ndlevel.htm """ if ( boundary.InternalOrExternalBoundary != "INTERNAL" or boundary.CorrespondingBoundary ): return candidates = cleaned_corresponding_candidates(boundary) corresponding_boundary = get_best_corresponding_candidate(boundary, candidates) if corresponding_boundary: boundary.CorrespondingBoundary = corresponding_boundary corresponding_boundary.CorrespondingBoundary = boundary elif boundary.PhysicalOrVirtualBoundary == "VIRTUAL" and seems_too_smal(boundary): logger.warning( f""" Boundary {boundary.Label} from space {boundary.RelatingSpace.Id} has been removed. It is VIRTUAL, INTERNAL, thin and has no corresponding boundary. It looks like a parasite.""" ) doc.removeObject(boundary.Name) else: # Considering test above. Assume that it has been missclassified but log the issue. boundary.InternalOrExternalBoundary = "EXTERNAL" logger.warning( f""" No corresponding boundary found for {boundary.Label} from space {boundary.RelatingSpace.Id}. Assigning to EXTERNAL assuming it was missclassified as INTERNAL""" ) def get_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 InvalidBoundary(LookupError): pass if __name__ == "__main__": pass diff --git a/freecad/bem/typing.pyi b/freecad/bem/typing.pyi index eb5cbd8..dd0ea64 100644 --- a/freecad/bem/typing.pyi +++ b/freecad/bem/typing.pyi @@ -1,134 +1,140 @@ # coding: utf8 """This module contains stubs for FreeCAD Feature Python. © All rights reserved. ECOLE POLYTECHNIQUE FEDERALE DE LAUSANNE, Switzerland, Laboratory CNPA, 2019-2020 See the LICENSE.TXT file for more details. Author : Cyril Waechter """ from typing import List import FreeCAD import Part from freecad.bem.entities import ( Root, Container, RelSpaceBoundary, BEMBoundary, Space, Element, Project, + Zone, ) from freecad.bem.materials import Material, LayerSet, ConstituentSet class RootFeature(Part.Feature): Proxy: Root = ... Group: List[Part.Feature] = ... IfcType: str = ... Id: int = ... GlobalId: str = ... IfcName: str = ... Description: str = ... Shape: Part.Shape = ... class RelSpaceBoundaryFeature(RootFeature): Proxy: RelSpaceBoundary = ... RelatingSpace: int = ... RelatedBuildingElement: ElementFeature = ... PhysicalOrVirtualBoundary: str = ... InternalOrExternalBoundary: str = ... CorrespondingBoundary: RelSpaceBoundaryFeature = ... ParentBoundary: int = ... InnerBoundaries: List[RelSpaceBoundaryFeature] = ... UndergroundDepth: PropertyLength = ... Normal: FreeCAD.Vector = ... ClosestBoundaries: List[int] = ... ClosestEdges: List[int] = ... ClosestDistance: List[int] = ... IsHosted: bool = ... PropertyArea: PropertyArea = ... AreaWithHosted: PropertyArea = ... SIA_Interior: BEMBoundaryFeature = ... SIA_Exterior: BEMBoundaryFeature = ... LesoType: str = ... class ElementFeature(RootFeature): Proxy: Element = ... Material: MaterialFeature = ... FillsVoids: List[ElementFeature] = ... HasOpenings: List[ElementFeature] = ... ProvidesBoundaries: List[int] = ... ThermalTransmittance: float = ... HostedElements: List[ElementFeature] = ... HostElement: int = ... Thickness: PropertyLength = ... class BEMBoundaryFeature(Part.Feature): Proxy: BEMBoundary = ... SourceBoundary: int = ... Area: PropertyArea = ... AreaWithHosted: PropertyArea = ... class ContainerFeature(RootFeature): Proxy: Container = ... class ProjectFeature(RootFeature): Proxy: Project = ... LongName: str = ... TrueNorth: FreeCAD.Vector = ... WorldCoordinateSystem: FreeCAD.Vector = ... ApplicationIdentifier: str = ... ApplicationVersion: str = ... ApplicationFullName: str = ... class SecondLevelGroup: Group: List[RelSpaceBoundaryFeature] = ... class SIAGroups: Group: List[BEMBoundaryFeature] = ... class SpaceFeature(RootFeature): Proxy: Space = ... LongName: str = ... Boundaries: List[SecondLevelGroup] = ... SecondLevel: SecondLevelGroup = ... SIA_Interiors: SIAGroups = ... SIA_Exteriors: SIAGroups = ... Area: PropertyArea = ... AreaAE: PropertyArea = ... +class ZoneFeature(RootFeature): + Proxy: Zone = ... + LongName: str = ... + RelatedObjects: List[SpaceFeature] = ... + class MaterialFeature(Part.Feature): Proxy: Material = ... Id: int = ... IfcName: str = ... Description: str = ... Category: str = ... MassDensity: str = ... Porosity: str = ... VisibleTransmittance: float = ... SolarTransmittance: float = ... ThermalIrTransmittance: float = ... ThermalIrEmissivityBack: float = ... ThermalIrEmissivityFront: float = ... SpecificHeatCapacity: float = ... ThermalConductivity: float = ... class LayerSetFeature(Part.Feature): Proxy: LayerSet Thicknesses: List[float] = ... Id: int = ... MaterialLayers: List[MaterialFeature] = ... IfcName: str = ... Description: str = ... TotalThickness: str = ... class ConstituentSetFeature(Part.Feature): Proxy: ConstituentSet Id: int = ... IfcName: str = ... Description: str = ... Fractions: List[float] = ... Categories: List[str] = ... MaterialConstituents: List[MaterialFeature] = ...