From 7fa289cae44a59d09af28b093100239d505edd34 Mon Sep 17 00:00:00 2001 From: Furgo <148809153+furgo16@users.noreply.github.com> Date: Mon, 5 May 2025 11:31:43 +0200 Subject: [PATCH] BIM: refactor component area calculation into a helper class --- src/Mod/BIM/ArchComponent.py | 272 +++++++++++++++++++++++------------ 1 file changed, 184 insertions(+), 88 deletions(-) diff --git a/src/Mod/BIM/ArchComponent.py b/src/Mod/BIM/ArchComponent.py index 1c2220fec6..b44613863b 100644 --- a/src/Mod/BIM/ArchComponent.py +++ b/src/Mod/BIM/ArchComponent.py @@ -961,99 +961,21 @@ class Component(ArchIFC.IfcProduct): def computeAreas(self,obj): """Compute the area properties of the object's shape. - Compute the vertical area, horizontal area, and perimeter length of - the object's shape. + This function calculates and assigns the following properties to the object: + - **VerticalArea**: The total area of all vertical faces of the object. + - **HorizontalArea**: The area of the object's projection onto the XY plane. + - **PerimeterLength**: The perimeter of the horizontal area. - The vertical area is the surface area of the faces perpendicular to the - ground. - - The horizontal area is the area of the shape, when projected onto a - hyperplane across the XY axes, IE: the area when viewed from a bird's - eye view. - - The perimeter length is the length of the outside edges of this bird's - eye view. - - Assign these values to the object's "VerticalArea", "HorizontalArea", - and "PerimeterLength" properties. + The function uses the `AreaCalculator` helper class to perform these calculations. + Refer to that class for more details on the calculation. Parameters ---------- - obj: - The component object. + obj : App::FeaturePython + The component object whose area properties are to be computed. """ - - - if (not obj.Shape) or obj.Shape.isNull() or (not obj.Shape.isValid()) or (not obj.Shape.Faces): - obj.VerticalArea = 0 - obj.HorizontalArea = 0 - obj.PerimeterLength = 0 - return - - import Part - import DraftGeomUtils - - fmax = params.get_param_arch("MaxComputeAreas") - if len(obj.Shape.Faces) > fmax: - obj.VerticalArea = 0 - obj.HorizontalArea = 0 - obj.PerimeterLength = 0 - return - - a = 0 - fset = [] - for i,f in enumerate(obj.Shape.Faces): - try: - ang = f.normalAt(0,0).getAngle(FreeCAD.Vector(0,0,1)) - except Part.OCCError: - print("Debug: Error computing areas for ",obj.Label,": normalAt() Face ",i) - obj.VerticalArea = 0 - obj.HorizontalArea = 0 - obj.PerimeterLength = 0 - return - else: - if ((ang > 1.57) and (ang < 1.571)): - # Ignore non-planar vertical surfaces for area calculation - # See https://github.com/FreeCAD/FreeCAD/issues/14687 - if f.Surface.isPlanar(): - a += f.Area - else: - fset.append(f) - - if a and hasattr(obj,"VerticalArea"): - if obj.VerticalArea.Value != a: - obj.VerticalArea = a - if fset and hasattr(obj,"HorizontalArea"): - pset = [] - for f in fset: - try: - import TechDraw - pf = Part.makeFace(DraftGeomUtils.findWires(TechDraw.project(f,FreeCAD.Vector(0,0,1))[0].Edges), "Part::FaceMakerCheese") - except Part.OCCError: - # error in computing the areas. Better set them to zero than show a wrong value - if obj.HorizontalArea.Value != 0: - print("Debug: Error computing areas for ",obj.Label,": unable to project face: ",str([v.Point for v in f.Vertexes])," (face normal:",f.normalAt(0,0),")") - obj.HorizontalArea = 0 - if hasattr(obj,"PerimeterLength"): - if obj.PerimeterLength.Value != 0: - obj.PerimeterLength = 0 - return - else: - pset.append(pf) - - if pset: - self.flatarea = pset.pop() - for f in pset: - self.flatarea = self.flatarea.fuse(f) - self.flatarea = self.flatarea.removeSplitter() - if obj.HorizontalArea.Value != self.flatarea.Area: - obj.HorizontalArea = self.flatarea.Area - if hasattr(obj,"PerimeterLength") and (len(self.flatarea.Faces) == 1): - edges_table = {} - for e in self.flatarea.Edges: - edges_table.setdefault(e.hashCode(),[]).append(e) - border_edges = [pair[0] for pair in edges_table.values() if len(pair) == 1] - obj.PerimeterLength = sum([e.Length for e in border_edges]) + calculator = AreaCalculator(obj) + calculator.compute() def isStandardCase(self,obj): """Determine if the component is a standard case of its IFC type. @@ -1189,6 +1111,180 @@ class Component(ArchIFC.IfcProduct): return True return False +class AreaCalculator: + """Helper class to compute vertical area, horizontal area, and perimeter length. + + This class encapsulates the logic for calculating the following properties: + - **VerticalArea**: The total area of all vertical faces of the object. See the + `isFaceVertical` method for the criteria used to determine vertical faces. + - **HorizontalArea**: The area of the object's projection onto the XY plane. + - **PerimeterLength**: The perimeter of the horizontal area. + + The class provides methods to validate the object's shape, identify vertical and + horizontal faces, and compute the required properties. + """ + def __init__(self, obj): + self.obj = obj + + def isShapeInvalid(self): + """Check if the object's shape is invalid.""" + return ( + not self.obj.Shape + or self.obj.Shape.isNull() + or not self.obj.Shape.isValid() + or not self.obj.Shape.Faces + ) + + def tooManyFaces(self): + """Check if the object's shape has too many faces to process.""" + return len(self.obj.Shape.Faces) > params.get_param_arch("MaxComputeAreas") + + def resetAreas(self): + """Reset the area properties of the object to zero. Generally called when + there is an error. + """ + for prop in ["VerticalArea", "HorizontalArea", "PerimeterLength"]: + setattr(self.obj, prop, 0) + + def isFaceVertical(self, face): + """Determine if a face is vertical. + + A face is considered vertical if: + - Its normal vector forms an angle close to 90 degrees with the Z-axis. + - The face is planar. + + Notes + ----- + The check whether the face is planar means that roof-like (sloped) and domed + faces alike will not be counted as vertical faces. It also means though, that + vertically-extruded curved edges (for instance from a slab) will also be + ignored. See also https://github.com/FreeCAD/FreeCAD/issues/14687. + """ + from Part import OCCError + try: + angle = face.normalAt(0, 0).getAngle(FreeCAD.Vector(0, 0, 1)) + return 1.57 < angle < 1.571 and face.Surface.isPlanar() + except OCCError: + FreeCAD.Console.PrintWarning( + translate("Arch", + f"Could not determine if a face from {self.obj.Label}" + " is vertical: normalAt() failed\n") + ) + return False + + def isFaceHorizontal(self, face): + """Determine if a face is horizontal. + + A face is considered horizontal if its normal vector is parallel to the Z-axis. + """ + from Part import OCCError + try: + angle = face.normalAt(0, 0).getAngle(FreeCAD.Vector(0, 0, 1)) + return angle <= 1.57 or angle >= 1.571 + except OCCError: + FreeCAD.Console.PrintWarning( + translate("Arch", + f"Could not determine if a face from {self.obj.Label}" + " is horizontal: normalAt() failed\n") + ) + return False + + def compute(self): + """Compute the vertical area, horizontal area, and perimeter length. + + This method performs the following steps: + 1. Identifies the object's vertical and horizontal faces. + 2. Computes the total vertical area by adding areas of all vertical faces. + 3. Projects horizontal faces onto the XY plane and computes their total horizontal area. + 4. Computes the perimeter length of the horizontal area. + + The computed values are assigned to the object's properties: + - VerticalArea + - HorizontalArea + - PerimeterLength + """ + if self.isShapeInvalid() or self.tooManyFaces(): + self.resetAreas() + return + + verticalArea = 0 + horizontalFaces = [] + + # Compute vertical area and collect horizontal faces + for face in self.obj.Shape.Faces: + if self.isFaceVertical(face): + verticalArea += face.Area + elif self.isFaceHorizontal(face): + horizontalFaces.append(face) + + # Update vertical area + if verticalArea and hasattr(self.obj, "VerticalArea"): + if self.obj.VerticalArea.Value != verticalArea: + self.obj.VerticalArea = verticalArea + + # Compute horizontal area and perimeter length + if horizontalFaces and hasattr(self.obj, "HorizontalArea"): + self._computeHorizontalAreaAndPerimeter(horizontalFaces) + + def _computeHorizontalAreaAndPerimeter(self, horizontalFaces): + """Compute the horizontal area and perimeter length. + + Projects the given horizontal faces onto the XY plane, fuses them, + and calculates: + - The total horizontal area. + - The perimeter length of the fused horizontal area. + + Parameters + ---------- + horizontalFaces : list of Part.Face + The horizontal faces to process. + + Notes + ----- + The operation of projecting faces is done with the `Part::FaceMakerCheese` + facemaker algorithm, so that holes in the faces are taken into account for + the area calculation. + """ + import Part + from DraftGeomUtils import findWires + from TechDraw import project + + projectedFaces = [] + for face in horizontalFaces: + try: + projectedEdges = project(face, FreeCAD.Vector(0, 0, 1))[0].Edges + wires = findWires(projectedEdges) + projectedFace = Part.makeFace(wires, "Part::FaceMakerCheese") + projectedFaces.append(projectedFace) + except Part.OCCError: + FreeCAD.Console.PrintWarning( + translate( + "Arch", + f"Error computing areas for {self.obj.Label}: unable to project or " + f"make face with normal {face.normalAt(0, 0)}. " + "Area values will be reset to 0.\n" + ) + ) + self.resetAreas() + return + + if projectedFaces: + fusedFace = projectedFaces.pop() + for face in projectedFaces: + fusedFace = fusedFace.fuse(face) + fusedFace = fusedFace.removeSplitter() + + if self.obj.HorizontalArea.Value != fusedFace.Area: + self.obj.HorizontalArea = fusedFace.Area + + if hasattr(self.obj, "PerimeterLength") and len(fusedFace.Faces) == 1: + edgeTable = {} + for edge in fusedFace.Edges: + edgeTable.setdefault(edge.hashCode(), []).append(edge) + borderEdges = [edges[0] for edges in edgeTable.values() if len(edges) == 1] + perimeterLength = sum(edge.Length for edge in borderEdges) + if self.obj.PerimeterLength.Value != perimeterLength: + self.obj.PerimeterLength = perimeterLength class ViewProviderComponent: """A default View Provider for Component objects.