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:
@@ -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}",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user