# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * * # * Copyright (c) 2025 Furgo * # * * # * This file is part of FreeCAD. * # * * # * FreeCAD is free software: you can redistribute it and/or modify it * # * under the terms of the GNU Lesser General Public License as * # * published by the Free Software Foundation, either version 2.1 of the * # * License, or (at your option) any later version. * # * * # * FreeCAD is distributed in the hope that it will be useful, but * # * WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * # * Lesser General Public License for more details. * # * * # * You should have received a copy of the GNU Lesser General Public * # * License along with FreeCAD. If not, see * # * . * # * * # *************************************************************************** # Unit tests for the ArchComponent module import Arch import Draft import Part import FreeCAD as App from bimtests import TestArchBase from draftutils.messages import _msg from math import pi, cos, sin, radians class TestArchComponent(TestArchBase.TestArchBase): def testAdd(self): App.Console.PrintLog("Checking Arch Add...\n") l = Draft.makeLine(App.Vector(0, 0, 0), App.Vector(2, 0, 0)) w = Arch.makeWall(l, width=0.2, height=2) sb = Part.makeBox(1, 1, 1) b = App.ActiveDocument.addObject("Part::Feature", "Box") b.Shape = sb App.ActiveDocument.recompute() Arch.addComponents(b, w) App.ActiveDocument.recompute() r = w.Shape.Volume > 1.5 self.assertTrue(r, "Arch Add failed") def testRemove(self): App.Console.PrintLog("Checking Arch Remove...\n") l = Draft.makeLine(App.Vector(0, 0, 0), App.Vector(2, 0, 0)) w = Arch.makeWall(l, width=0.2, height=2, align="Right") sb = Part.makeBox(1, 1, 1) b = App.ActiveDocument.addObject("Part::Feature", "Box") b.Shape = sb App.ActiveDocument.recompute() Arch.removeComponents(b, w) App.ActiveDocument.recompute() r = w.Shape.Volume < 0.75 self.assertTrue(r, "Arch Remove failed") def testBsplineSlabAreas(self): """Test the HorizontalArea and VerticalArea properties of a Bspline-based slab. See https://github.com/FreeCAD/FreeCAD/issues/20989. """ operation = "Checking Bspline slab area calculation..." self.printTestMessage(operation) doc = App.ActiveDocument # Parameters radius = 10000 # 10 meters in mm extrusionLength = 100 # 10 cm in mm numPoints = 50 # Number of points for B-spline startAngle = 0 # Start at 0 degrees (right) endAngle = 180 # End at 180 degrees (left) # Create points for semicircle points = [] angleStep = (endAngle - startAngle) / (numPoints - 1) for i in range(numPoints): angleDeg = startAngle + i * angleStep angleRad = radians(angleDeg) x = radius * cos(angleRad) y = radius * sin(angleRad) points.append(App.Vector(x, y, 0)) # Create Draft objects bspline = Draft.makeBSpline(points, closed=False) closingLine = Draft.makeLine(points[-1], points[0]) doc.recompute() # Create sketch # We do this because Draft.make_wires does not support B-splines # and we need a closed wire for the slab sketch = Draft.makeSketch([bspline, closingLine], autoconstraints=True, delete=True) if sketch is None: self.fail("Sketch creation failed") sketch.recompute() # Create slab slab = Arch.makeStructure(sketch, length=extrusionLength, name="Slab") slab.recompute() # Calculate theoretical areas radiusMeters = radius / 1000 heightMeters = extrusionLength / 1000 theoreticalHorizontalArea = (pi * radiusMeters**2) / 2 theoreticalVerticalArea = (pi * radiusMeters + 2 * radiusMeters) * heightMeters # Get actual areas actualHorizontalArea = slab.HorizontalArea.getValueAs("m^2").Value actualVerticalArea = slab.VerticalArea.getValueAs("m^2").Value # Optimally wrapped assertions self.assertAlmostEqual( actualHorizontalArea, theoreticalHorizontalArea, places=3, msg=( "Horizontal area > 0.1% tolerance | " f"Exp: {theoreticalHorizontalArea:.3f} m² | " f"Got: {actualHorizontalArea:.3f} m²" ), ) self.assertAlmostEqual( actualVerticalArea, theoreticalVerticalArea, places=3, msg=( "Vertical area > 0.1% tolerance | " f"Exp: {theoreticalVerticalArea:.3f} m² | " f"Got: {actualVerticalArea:.3f} m²" ), ) def testHouseSpaceAreas(self): """Test the HorizontalArea and VerticalArea properties of a house-like space. See https://github.com/FreeCAD/FreeCAD/issues/14687. """ operation = "Checking house space area calculation..." self.printTestMessage(operation) doc = App.ActiveDocument # Dimensional parameters (all in mm) baseLength = 5000 # 5m along X-axis baseWidth = 5000 # 5m along Y-axis (extrusion depth) rectangleHeight = 2500 # 2.5m lower rectangular portion triangleHeight = 2500 # 2.5m upper triangular portion totalHeight = rectangleHeight + triangleHeight # 5m total height # Create envelope profile points (XZ plane) points = [ App.Vector(0, 0, 0), App.Vector(baseLength, 0, 0), App.Vector(baseLength, 0, rectangleHeight), App.Vector(baseLength / 2, 0, totalHeight), App.Vector(0, 0, rectangleHeight), ] # Create wire with automatic face creation wire = Draft.makeWire(points, closed=True, face=True) if not wire: self.fail(f"Wire creation failed with points: {points}\n") doc.recompute() # Extrude the wire extrudedObj = Draft.extrude(wire, App.Vector(0, baseWidth, 0), solid=True) if not extrudedObj: self.fail("Extrusion failed - no object created\n") extrudedObj.Label = "Extruded house" doc.recompute() # Create Arch Space from the extrusion space = Arch.makeSpace(extrudedObj) space.Label = "House space" doc.recompute() # Calculate theoretical areas # Horizontal area (only bottom face on XY plane) theoreticalHorizontalArea = (baseLength * baseWidth) / 1e6 # 25 m² # Vertical areas # Side faces (YZ plane) - two rectangles sideFaceArea = (rectangleHeight * baseWidth) / 1e6 # 12.5 m² each totalSides = sideFaceArea * 2 # 25 m² # Front/back faces (XZ plane) rectangularPart = (baseLength * rectangleHeight) / 1e6 # 12.5 m² triangularPart = (baseLength * triangleHeight / 2) / 1e6 # 6.25 m² totalFrontBack = (rectangularPart + triangularPart) * 2 # 37.5 m² theoreticalVerticalArea = totalSides + totalFrontBack # 62.5 m² # Get actual areas from space actualHorizontalArea = space.HorizontalArea.getValueAs("m^2").Value actualVerticalArea = space.VerticalArea.getValueAs("m^2").Value self.assertAlmostEqual( actualHorizontalArea, theoreticalHorizontalArea, places=3, msg=f"Horizontal area > 0.1% | Exp: {theoreticalHorizontalArea:.3f} | " f"Got: {actualHorizontalArea:.3f}", ) self.assertAlmostEqual( actualVerticalArea, theoreticalVerticalArea, places=3, msg=f"Vertical area > 0.1% | Exp: {theoreticalVerticalArea:.3f} | " f"Got: {actualVerticalArea:.3f}", ) def test_remove_single_window_from_wall_host_is_none(self): """ Tests that a window is removed from its wall's host list when Arch.removeComponents is called with host=None (single window selection scenario). See https://github.com/FreeCAD/FreeCAD/issues/21551 """ # Create a basic wall wall_base = Draft.makeLine(App.Vector(0, 0, 0), App.Vector(3000, 0, 0)) wall = Arch.makeWall(wall_base, width=200, height=2500) # Create a window to be added to the wall window_width = 1000.0 window_height = 1200.0 # Create a Draft Rectangle for the window's Base. Arch.makeWindow can work with # either a Wire or a Face. window_base = Draft.makeRectangle(length=window_width, height=window_height) window = Arch.makeWindow(baseobj=window_base) window.Width = window_width # Manually set as makeWindow(base) doesn't window.Height = window_height # Manually set self.document.recompute() # Add the window to the wall. Arch.addComponents(window, wall) self.document.recompute() # Pre-condition check: ensure the wall is indeed a host for the window. self.assertIn(wall, window.Hosts, "Wall should be in window.Hosts before removal.") self.assertEqual(len(window.Hosts), 1, "Window should have 1 host before removal.") # Simulate the Arch_Remove command with the scenario where only the window # is selected and "Remove" is used. Arch.removeComponents([window], host=None) self.document.recompute() # Important for Arch objects to update their state. # 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.")