BIM: refactor component area calculation into a helper class

This commit is contained in:
Furgo
2025-05-05 11:31:43 +02:00
committed by Yorik van Havre
parent cc70fee4d7
commit 7fa289cae4

View File

@@ -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: <App::FeaturePython>
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.