diff --git a/freecad/bem/ifc_importer.py b/freecad/bem/ifc_importer.py index a811496..06edddd 100644 --- a/freecad/bem/ifc_importer.py +++ b/freecad/bem/ifc_importer.py @@ -1,685 +1,708 @@ # 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, ) 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) + # 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) 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)): 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: + 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/utils.py b/freecad/bem/utils.py index 9bde574..e206216 100644 --- a/freecad/bem/utils.py +++ b/freecad/bem/utils.py @@ -1,348 +1,358 @@ # coding: utf8 """This module contains various utility functions not specific to another module. © 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 typing from typing import Iterable, Any, Generator, List import FreeCAD import Part from freecad.bem.entities import Root if typing.TYPE_CHECKING: from freecad.bem.typing import ( RelSpaceBoundaryFeature, ) # pylint: disable=no-name-in-module, import-error TOLERANCE = 0.001 def append(doc_object, fc_property, value: Any): """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 append_inner_wire(boundary: "RelSpaceBoundaryFeature", wire: Part.Wire) -> None: """Intended to manipulate FreeCAD list like properties only""" outer_wire = get_outer_wire(boundary) inner_wires = get_inner_wires(boundary) inner_wires.append(wire) generate_boundary_compound(boundary, outer_wire, inner_wires) def are_parallel_boundaries( boundary1: "RelSpaceBoundaryFeature", boundary2: "RelSpaceBoundaryFeature" ) -> bool: return ( 1 - abs(get_boundary_normal(boundary1).dot(get_boundary_normal(boundary2))) < TOLERANCE ) def are_too_far(boundary1, boundary2): max_distance = getattr( getattr(boundary2.RelatedBuildingElement, "Thickness", 0), "Value", 0 ) return boundary1.Shape.distToShape(boundary2.Shape)[0] - max_distance > TOLERANCE def clean_vectors(vectors: List[FreeCAD.Vector]) -> None: """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""" i = 0 while i < len(vectors): pt1 = vectors[i - 1] pt2 = vectors[i] pt3 = vectors[(i + 1) % len(vectors)] if are_3points_collinear(pt1, pt2, pt3): vectors.pop(i) i = i - 1 if i > 0 else 0 continue i += 1 def close_vectors(vectors: List[FreeCAD.Vector]) -> None: if vectors[0] != vectors[-1]: vectors.append(vectors[0]) def direction(vec0: FreeCAD.Vector, vec1: FreeCAD.Vector) -> FreeCAD.Vector: return (vec0 - vec1).normalize() def generate_boundary_compound( boundary: "RelSpaceBoundaryFeature", outer_wire: Part.Wire, inner_wires: List[Part.Wire], ): """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.Proxy, Root) else boundary.SourceBoundary.Id ) raise RuntimeError( 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}""" ) face = new_face boundary.Shape = Part.Compound([face, outer_wire, *inner_wires]) def get_axis_by_name(placement, name): axis_dict = {"AXIS1": 0, "AXIS2": 1, "AXIS3": 2} return FreeCAD.Vector(placement.Matrix.A[axis_dict[name] : 12 : 4]) def get_by_id(ifc_id: int, elements: Iterable[Part.Feature]) -> Part.Feature: for element in elements: try: if element.Id == ifc_id: return element except AttributeError: continue +def get_object(ifc_entity, doc) -> Part.Feature: + entity_id = ifc_entity.id() + for element in doc.Objects: + try: + if element.Id == entity_id: + return element + except AttributeError: + continue + + 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_boundaries_by_element(element: Part.Feature, doc) -> List[Part.Feature]: return [ boundary for boundary in get_elements_by_ifctype("IfcRelSpaceBoundary", doc) if boundary.RelatedBuildingElement == element ] def get_boundaries_by_element_id(element_id, doc): return ( boundary for boundary in doc.Objects if getattr(getattr(boundary, "RelatedBuilding", None), "Id", None) == element_id ) def get_face_ref_point(face): center_of_mass = face.CenterOfMass if face.isInside(center_of_mass, TOLERANCE, True): return center_of_mass pt1 = face.distToShape(Part.Vertex(center_of_mass))[1][0][0] line = Part.Line(center_of_mass, pt1) plane = Part.Plane(center_of_mass, face.normalAt(0, 0)) intersections = [line.intersect2d(e.Curve, plane) for e in face.Edges] intersections = [i[0] for i in intersections if i] if not len(intersections) == 2: intersections = sorted(intersections)[0:2] mid_param = tuple(sum(params) / len(params) for params in zip(*intersections)) return plane.value(*mid_param) def get_element_by_guid(guid, elements_group): for fc_element in getattr(elements_group, "Group", elements_group): if getattr(fc_element, "GlobalId", None) == guid: return fc_element raise LookupError( f"""Unable to get element by {guid}. This error is known to occurs when you model 2 parallel walls instead of a multilayer wall.""" ) def get_host_guid(ifc_entity): return ( ifc_entity.FillsVoids[0] .RelatingOpeningElement.VoidsElements[0] .RelatingBuildingElement.GlobalId ) def get_in_list_by_id(elements, element_id): if element_id == -1: return None for element in elements: if element.Id == element_id: return element raise LookupError(f"No element with Id <{element_id}> found") def get_face_normal(face, at_point=None) -> FreeCAD.Vector: at_point = at_point or face.Vertexes[0].Point params = face.Surface.projectPoint(at_point, "LowerDistanceParameters") return face.normalAt(*params) def get_boundary_normal(fc_boundary, at_point=None) -> FreeCAD.Vector: return get_face_normal(fc_boundary.Shape.Faces[0], at_point) def get_plane(fc_boundary) -> Part.Plane: """Intended for RelSpaceBoundary use only""" vertexes = get_outer_wire(fc_boundary).Vertexes vec1, vec2 = [v.Point for v in vertexes[0:2]] for vtx in vertexes[2:]: try: return Part.Plane(vec1, vec2, vtx.Point) except Part.OCCError: continue def get_area_from_points(points: List[FreeCAD.Vector]) -> float: """Return area considering points are consecutive points of a polygon Return 0 for invalid polygons""" clean_vectors(points) close_vectors(points) try: return Part.Face(Part.makePolygon(points)).Area except Part.OCCError: return 0 def get_vectors_from_shape(shape: Part.Shape): return [vx.Point for vx in shape.Vertexes] def get_boundary_outer_vectors(boundary): return [vx.Point for vx in get_outer_wire(boundary).Vertexes] 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 get_wires(boundary: Part.Feature) -> Generator[Part.Wire, None, None]: return (s for s in boundary.Shape.SubShapes if isinstance(s, Part.Wire)) def are_edges_parallel(edge1: Part.Edge, edge2: Part.Edge) -> bool: dir1 = line_from_edge(edge1).Direction dir2 = line_from_edge(edge2).Direction return abs(dir1.dot(dir2)) > 1 - TOLERANCE def is_coplanar(boundary1, boundary2): """Intended for RelSpaceBoundary use only For some reason native Part.Shape.isCoplanar(Part.Shape) do not always work""" plane1 = get_plane(boundary1) plane2 = get_plane(boundary2) same_dir = plane1.Axis.dot(plane2.Axis) > 1 - TOLERANCE p2_on_plane = ( # Strangely distanceToPlane can be negative abs(plane2.Position.distanceToPlane(plane1.Position, plane1.Axis)) < TOLERANCE ) return same_dir and p2_on_plane 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, base_plane): new_points = [] for li1, line1 in enumerate(lines): li2 = li1 - 1 line2 = lines[li2] # Need to ensure direction are not same to avoid crash in OCCT 7.4 if abs(line1.Direction.dot(line2.Direction)) >= 1 - TOLERANCE: continue new_points.append(base_plane.value(*line1.intersect2d(line2, base_plane)[0])) clean_vectors(new_points) if len(new_points) < 3: raise ShapeCreationError close_vectors(new_points) return Part.makePolygon(new_points) def project_wire_to_plane(wire, plane) -> Part.Wire: new_vectors = [ v.Point.projectToPlane(plane.Position, plane.Axis) for v in wire.Vertexes ] close_vectors(new_vectors) return Part.makePolygon(new_vectors) def project_boundary_onto_plane(boundary, plane: Part.Plane): outer_wire = get_outer_wire(boundary) inner_wires = get_inner_wires(boundary) outer_wire = project_wire_to_plane(outer_wire, plane) inner_wires = [project_wire_to_plane(wire, plane) for wire in inner_wires] generate_boundary_compound(boundary, outer_wire, inner_wires) def remove_inner_wire(boundary, wire) -> None: if wire in boundary.Shape.Wires: boundary.Shape = boundary.Shape.removeShape([wire]) return area = Part.Face(wire).Area for inner_wire in get_inner_wires(boundary): if abs(Part.Face(inner_wire).Area - area) < TOLERANCE: boundary.Shape = boundary.Shape.removeShape([inner_wire]) return def update_boundary_shape(boundary) -> None: outer_wire = get_outer_wire(boundary) inner_wires = get_inner_wires(boundary) generate_boundary_compound(boundary, outer_wire, inner_wires) def are_3points_collinear( pt1: FreeCAD.Vector, pt2: FreeCAD.Vector, pt3: FreeCAD.Vector ) -> bool: for vec1, vec2 in itertools.combinations((pt1, pt2, pt3), 2): if vec1.isEqual(vec2, TOLERANCE): return True dir1 = vectors_dir(pt1, pt2) dir2 = vectors_dir(pt2, pt3) return dir1.isEqual(dir2, TOLERANCE) or dir1.isEqual(-dir2, TOLERANCE) def vectors_dir(pt1: FreeCAD.Vector, pt2: FreeCAD.Vector) -> FreeCAD.Vector: return (pt2 - pt1).normalize() class IsTooSmall(BaseException): pass class ShapeCreationError(RuntimeError): pass