BIM: add vertical area tests (#26874)

* BIM: Add tests for ArchComponent.AreaCalculator.isFaceVertical

* BIM: test new area calculation fallback case

* BIM: add test for composite complex surface vertical area calculation
This commit is contained in:
Furgo
2026-01-19 15:12:18 +01:00
committed by GitHub
parent ab40312ab0
commit f159133737

View File

@@ -263,3 +263,251 @@ class TestArchComponent(TestArchBase.TestArchBase):
# Assert: the wall should no longer be in the window's Hosts list.
self.assertNotIn(wall, window.Hosts, "Wall should not be in window.Hosts after removal.")
self.assertEqual(len(window.Hosts), 0, "Window.Hosts list should be empty after removal.")
def test_if_face_vertical(self):
"""
Test the ArchComponent.AreaCalculator.isFaceVertical method directly.
Verifies classification of standard walls, periodic surfaces (cylinders),
extruded surfaces (B-Splines), and sloped faces (tapered wedge) using
white-box testing on the internal calculator.
"""
import math
import ArchComponent
tolerance = Part.Precision.confusion()
def get_normal_z(face):
return abs(face.normalAt(0, 0).z)
def is_vertical(normal_z):
return math.isclose(normal_z, 0.0, abs_tol=tolerance)
def is_horizontal(normal_z):
return math.isclose(normal_z, 1.0, abs_tol=tolerance)
def is_sloped(normal_z):
return not (is_vertical(normal_z) or is_horizontal(normal_z))
with self.subTest(case="Standard Wall"):
line = Draft.makeLine(App.Vector(0, 0, 0), App.Vector(10, 0, 0))
wall = Arch.makeWall(line, width=2, height=10)
self.document.recompute()
calc = ArchComponent.AreaCalculator(wall)
vertical_count = 0
for face in wall.Shape.Faces:
is_vert = calc.isFaceVertical(face)
normal_z = get_normal_z(face)
if is_vertical(normal_z):
self.assertTrue(is_vert, "Side face should be vertical")
vertical_count += 1
elif is_horizontal(normal_z):
self.assertFalse(is_vert, "Top/Bottom face should not be vertical")
self.assertEqual(
vertical_count, 4, f"Expected 4 vertical sides on wall, found {vertical_count}"
)
with self.subTest(case="Closed Extrusion"):
points = [App.Vector(0, 0, 0), App.Vector(10, 0, 0), App.Vector(5, 5, 0)]
bspline = Draft.makeBSpline(points, closed=True)
structure = Arch.makeStructure(bspline, height=10)
self.document.recompute()
calc_bspline = ArchComponent.AreaCalculator(structure)
extrusion_vert_count = 0
for face in structure.Shape.Faces:
is_vert = calc_bspline.isFaceVertical(face)
if "SurfaceOfExtrusion" in face.Surface.TypeId:
self.assertTrue(is_vert, "Extruded B-Spline surface should be vertical")
extrusion_vert_count += 1
self.assertGreater(
extrusion_vert_count,
0,
f"Expected at least one vertical extruded face, found {extrusion_vert_count}",
)
with self.subTest(case="Cylinder"):
circle = Draft.makeCircle(radius=5)
struct = Arch.makeStructure(circle, height=20)
self.document.recompute()
calc_struct = ArchComponent.AreaCalculator(struct)
cyl_vertical_count = 0
for face in struct.Shape.Faces:
is_vert = calc_struct.isFaceVertical(face)
if "Cylinder" in face.Surface.TypeId:
self.assertTrue(is_vert, "Cylindrical face should be vertical")
cyl_vertical_count += 1
else:
self.assertFalse(is_vert, "Caps of cylinder should not be vertical")
self.assertEqual(
cyl_vertical_count,
1,
f"Expected exactly 1 vertical face on cylinder, found {cyl_vertical_count}",
)
with self.subTest(case="Generic Vertical Surface"):
# Create two B-spline curves, vertically aligned
points1 = [App.Vector(0, 0, 0), App.Vector(5, 5, 0), App.Vector(10, 0, 0)]
bspline1 = Draft.makeBSpline(points1, closed=False)
points2 = [App.Vector(0, 0, 20), App.Vector(5, 5, 20), App.Vector(10, 0, 20)]
bspline2 = Draft.makeBSpline(points2, closed=False)
# Create a ruled surface (Loft)
loft = self.document.addObject("Part::Loft", "GenericVerticalLoft")
loft.Sections = [bspline1, bspline2]
loft.Solid = False
loft.Ruled = True
self.document.recompute()
comp = Arch.makeComponent(loft)
calc_loft = ArchComponent.AreaCalculator(comp)
generic_vertical_count = 0
for face in comp.Shape.Faces:
if calc_loft.isFaceVertical(face):
generic_vertical_count += 1
self.assertEqual(
generic_vertical_count,
1,
f"Expected generic vertical surface to be detected as vertical, found {generic_vertical_count}",
)
with self.subTest(case="Tapered Wedge"):
wedge = self.document.addObject("Part::Wedge", "Wedge")
wedge.Ymin = 0
wedge.Zmin = 0
wedge.Xmin = 0
wedge.Ymax = 10
wedge.Zmax = 10
wedge.Xmax = 10
# Taper top to create slopes
wedge.X2min = 2
wedge.X2max = 8
wedge.Z2min = 2
wedge.Z2max = 8
self.document.recompute()
comp = Arch.makeComponent(wedge)
calc_wedge = ArchComponent.AreaCalculator(comp)
tapered_vertical_count = 0
for face in comp.Shape.Faces:
normal_z = get_normal_z(face)
if is_vertical(normal_z):
self.assertTrue(
calc_wedge.isFaceVertical(face), "X-Tapered face should be vertical"
)
tapered_vertical_count += 1
elif is_sloped(normal_z):
self.assertFalse(
calc_wedge.isFaceVertical(face), "Sloped face must not be vertical"
)
self.assertGreater(
tapered_vertical_count,
0,
f"Expected at least one vertical face on tapered wedge, found {tapered_vertical_count}",
)
def test_vertical_area_update_to_zero(self):
"""
Verify that VerticalArea property updates correctly even when the result is zero.
"""
line = Draft.makeLine(App.Vector(0, 0, 0), App.Vector(10, 0, 0))
wall = Arch.makeWall(line, width=2, height=10)
self.document.recompute()
initial_area = wall.VerticalArea.Value
self.assertGreater(
initial_area, 0, f"Setup error: Wall should have vertical area, found {initial_area}"
)
# Get the footprint to simulate a flat wall (valid shape, but 0 height)
footprint_faces = wall.Proxy.getFootprint(wall)
# Exactly one face is expected for this particular footprint (straight wall)
self.assertEqual(len(footprint_faces), 1, "Setup error: Expected exactly 1 footprint face")
# Manually assign the single footprint face as the wall shape. We do this as an alternative
# to setting height=0, because ArchWall.applyShape protects against null shapes (which
# height=0 produces), preventing area updates
wall.Shape = footprint_faces[0]
wall.Proxy.computeAreas(wall)
final_area = wall.VerticalArea.Value
self.assertEqual(final_area, 0.0, f"VerticalArea must update to zero, found {final_area}")
def test_complex_composite_area(self):
"""
Integration test: verify that AreaCalculator correctly sums areas from
mixed geometry types (planar, cylindrical, and generic) within a single object,
while correctly ignoring horizontal faces.
"""
import Part
from math import pi
# Create planar geometry (Box)
# 10x10x10 box.
# 4 Vertical faces = 10 * 10 * 4 = 400.
# 2 Horizontal faces (Top/Bottom) should be ignored.
box = Part.makeBox(10, 10, 10)
box.translate(App.Vector(0, 0, 0))
expected_box_v_area = 400.0
# Create cylindrical geometry (Cylinder)
# Radius 5, Height 10.
# 1 Vertical Face = 2 * pi * r * h = 100 * pi.
# 2 Horizontal faces (caps) should be ignored.
cyl = Part.makeCylinder(5, 10)
cyl.translate(App.Vector(20, 0, 0))
expected_cyl_v_area = 100 * pi
# Create generic geometry (Ruled Surface / Loft)
# Reuse the B-Spline logic that triggers the fallback projection path
points1 = [App.Vector(40, 0, 0), App.Vector(45, 5, 0), App.Vector(50, 0, 0)]
bspline1 = Draft.makeBSpline(points1, closed=False)
points2 = [App.Vector(40, 0, 10), App.Vector(45, 5, 10), App.Vector(50, 0, 10)]
bspline2 = Draft.makeBSpline(points2, closed=False)
loft = self.document.addObject("Part::Loft", "IntegrationLoft")
loft.Sections = [bspline1, bspline2]
loft.Solid = False
loft.Ruled = True
self.document.recompute()
# The entire loft is a vertical surface, so we take its total area.
expected_loft_v_area = loft.Shape.Area
# Combine into an Arch Component using a compound to simulate a complex single object
compound_shape = Part.makeCompound([box, cyl, loft.Shape])
complex_obj = Arch.makeComponent(compound_shape, name="ComplexStructure")
# Execute calculation
complex_obj.Proxy.computeAreas(complex_obj)
# Verify
total_expected = expected_box_v_area + expected_cyl_v_area + expected_loft_v_area
self.assertAlmostEqual(
complex_obj.VerticalArea.Value,
total_expected,
places=3,
msg=f"Failed to aggregate vertical areas of mixed types. Expected {total_expected}, got {complex_obj.VerticalArea.Value}",
)