diff --git a/freecad/bem/ifc_importer.py b/freecad/bem/ifc_importer.py index cbbc5fb..4b0693c 100644 --- a/freecad/bem/ifc_importer.py +++ b/freecad/bem/ifc_importer.py @@ -1,517 +1,521 @@ # 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.entities import ( RelSpaceBoundary, Element, 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_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 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.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"): 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": # TODO: How to do this as ifcopenshell.ifcopenshell_wrapper has no parse_ifcxml ? raise NotImplementedError("No support for .ifcXML yet") 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 elements_group = get_or_create_group("Elements", doc) ifc_elements = ( e for e in ifc_file.by_type("IfcElement") if e.ProvidesBoundaries ) for ifc_entity in ifc_elements: elements_group.addObject(Element.create_from_ifc(ifc_entity, self)) # Generate projects structure and boundaries for ifc_project in ifc_file.by_type("IfcProject"): project = Project.create_from_ifc(ifc_project, self) self.generate_containers(ifc_project, project) # Associate CorrespondingBoundary associate_corresponding_boundaries(doc) # Associate Host / Hosted elements associate_host_element(ifc_file, elements_group) # Associate hosted elements an fill gaps for fc_space in get_elements_by_ifctype("IfcSpace", doc): fc_boundaries = fc_space.SecondLevel.Group # Minimal number of boundary is 5: 3 vertical faces, 2 horizontal faces # If there is less than 5 boundaries there is an issue or a new case to analyse if len(fc_boundaries) == 5: continue elif len(fc_boundaries) < 5: assert ValueError, f"{fc_space.Label} has less than 5 boundaries" # Associate hosted elements associate_inner_boundaries(fc_boundaries, doc) def 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 ifc_entity.Representation: 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 bbox = self.element_local_shape_by_brep(ifc_entity).BoundBox # 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 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", "IfcRoof") 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 b.Name == "2ndLevel"): try: fc_boundary = RelSpaceBoundary.create_from_ifc( ifc_entity=ifc_boundary, ifc_importer=self ) 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(position.RefDirection.DirectionRatios) v_3 = FreeCAD.Vector(position.Axis.DirectionRatios) v_2 = v_3.cross(v_1) # fmt: off matrix = FreeCAD.Matrix( v_1.x, v_2.x, v_3.x, location.x, v_1.y, v_2.y, v_3.y, location.y, v_1.z, v_2.z, v_3.z, location.z, 0, 0, 0, 1, ) # fmt: on return matrix def 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: 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 Part 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 Part 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) utils.close_vectors(fc_verts) return Part.makePolygon(fc_verts) class CommonSegment(NamedTuple): index1: int index2: int opposite_dir: FreeCAD.Vector def associate_host_element(ifc_file, elements_group): # Associate Host / Hosted elements ifc_elements = (e for e in ifc_file.by_type("IfcElement") if e.ProvidesBoundaries) for ifc_entity in ifc_elements: if ifc_entity.FillsVoids: - host = utils.get_element_by_guid( + 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) hosted.HostElement = host.Id def associate_inner_boundaries(fc_boundaries, doc): """Associate hosted elements like a window or a door in a wall""" for fc_boundary in fc_boundaries: if not fc_boundary.IsHosted: continue candidates = set(fc_boundaries).intersection( utils.get_boundaries_by_element_id( fc_boundary.RelatedBuildingElement.HostElement, doc ) ) # If there is more than 1 candidate it doesn't really matter # as they share the same host element and space try: host_element = candidates.pop() except KeyError: logger.warning( f"RelSpaceBoundary Id<{fc_boundary.Id}> is hosted but host not found. Investigations required." ) continue fc_boundary.ParentBoundary = host_element.Id utils.append(host_element, "InnerBoundaries", fc_boundary) 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 clean_corresponding_candidates(fc_boundary, doc): other_boundaries = utils.get_boundaries_by_element( fc_boundary.RelatedBuildingElement, doc ) other_boundaries.remove(fc_boundary) return [ b for b in other_boundaries if not b.CorrespondingBoundary or b.RelatingSpace != fc_boundary.RelatingSpace ] def associate_corresponding_boundary(fc_boundary, doc): """Associate corresponding boundaries according to IFC definition. Reference to the other space boundary of the pair of two space boundaries on either side of a space separating thermal boundary element. https://standards.buildingsmart.org/IFC/RELEASE/IFC4_1/FINAL/HTML/link/ifcrelspaceboundary2ndlevel.htm """ if ( fc_boundary.InternalOrExternalBoundary != "INTERNAL" or fc_boundary.CorrespondingBoundary ): return other_boundaries = clean_corresponding_candidates(fc_boundary, doc) if len(other_boundaries) == 1: corresponding_boundary = other_boundaries[0] else: center_of_mass = utils.get_outer_wire(fc_boundary).CenterOfMass min_lenght = 10000 # No element has 10 m thickness for boundary in other_boundaries: distance = center_of_mass.distanceToPoint( utils.get_outer_wire(boundary).CenterOfMass ) if distance < min_lenght: min_lenght = distance corresponding_boundary = boundary try: fc_boundary.CorrespondingBoundary = corresponding_boundary corresponding_boundary.CorrespondingBoundary = fc_boundary except NameError: # Considering test above. Assume that it has been missclassified but log the issue. fc_boundary.InternalOrExternalBoundary = "EXTERNAL" logger.warning(f"Boundary {fc_boundary.GlobalId} from space {fc_boundary}") return 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) if __name__ == "__main__": pass diff --git a/freecad/bem/utils.py b/freecad/bem/utils.py index 995f3a9..343e979 100644 --- a/freecad/bem/utils.py +++ b/freecad/bem/utils.py @@ -1,282 +1,283 @@ # 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_normal_at(boundary1).dot(get_normal_at(boundary2))) < 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) and len(vectors) > 3: 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 ) 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_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_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_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}") + 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_normal_at(fc_boundary, at_uv=(0, 0)) -> FreeCAD.Vector: return fc_boundary.Shape.Faces[0].normalAt(*at_uv) def get_plane(fc_boundary) -> Part.Plane: """Intended for RelSpaceBoundary use only""" return Part.Plane(fc_boundary.Shape.Vertexes[0].Point, get_normal_at(fc_boundary)) 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 is_collinear(edge1, edge2): v0_0, v0_1 = (vx.Point for vx in edge1.Vertexes) v1_0, v1_1 = (vx.Point for vx in edge2.Vertexes) if is_collinear_or_parallel(v0_0, v0_1, v1_0, v1_1): return v0_0 == v1_0 or is_collinear_or_parallel(v0_0, v0_1, v0_0, v1_0) def is_collinear_or_parallel(v0_0, v0_1, v1_0, v1_1) -> bool: return abs(direction(v0_0, v0_1).dot(direction(v1_0, v1_1))) > 0.9999 def is_coplanar(shape_1, shape_2): """Intended for RelSpaceBoundary use only For some reason native Part.Shape.isCoplanar(Part.Shape) do not always work""" return get_plane(shape_1).toShape().isCoplanar(get_plane(shape_2).toShape()) def 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 line1, line2 in zip(lines, lines[1:] + lines[:1]): try: # Need to ensure direction are not same to avoid crash if abs(line1.Direction.dot(line2.Direction)) >= 1 - TOLERANCE: continue new_points.append( base_plane.value(*line1.intersect2d(line2, base_plane)[0]) ) except IndexError: raise NoIntersectionError if len(new_points) < len(lines): raise NoIntersectionError new_points[0:0] = new_points[-1:] 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] face = Part.Face(outer_wire) try: for inner_wire in inner_wires: face = face.cut(Part.Face(inner_wire)) except RuntimeError: pass boundary.Shape = Part.Compound([face, 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 NoIntersectionError(IndexError): pass class ShapeCreationError(RuntimeError): pass