From 47cec43aaef23ff14d42f0772caea9a8cae42ae2 Mon Sep 17 00:00:00 2001 From: Furgo <148809153+furgo16@users.noreply.github.com> Date: Wed, 19 Mar 2025 10:45:26 +0100 Subject: [PATCH] BIM: allow boundaries to be defined from a single object (e.g. wall) (#20158) * BIM: Add test for space from single wall boundaries * BIM: Arch_Space, enable creation of spaces from single objects with boundaries * BIM: update and expand docstring --- src/Mod/BIM/Arch.py | 88 ++++++++++++++++++++++++----- src/Mod/BIM/TestArch.py | 52 +++++++++++++++++ src/Mod/BIM/bimcommands/BimSpace.py | 5 +- 3 files changed, 127 insertions(+), 18 deletions(-) diff --git a/src/Mod/BIM/Arch.py b/src/Mod/BIM/Arch.py index 97f345ddb3..b5de643061 100644 --- a/src/Mod/BIM/Arch.py +++ b/src/Mod/BIM/Arch.py @@ -768,33 +768,93 @@ def makeSite(objectslist=None,baseobj=None,name=None): def makeSpace(objects=None,baseobj=None,name=None): + """Creates a space object from the given objects. - """makeSpace([objects],[baseobj],[name]): Creates a space object from the given objects. - Objects can be one document object, in which case it becomes the base shape of the space - object, or a list of selection objects as got from getSelectionEx(), or a list of tuples - (object, subobjectname)""" + Parameters + ---------- + objects : object or List() or App::PropertyLinkSubList, optional + The object or selection set that defines the space. If a single object is given, + it becomes the base shape for the object. If the object or selection set contains + subelements, these will be used as the boundaries to create the space. By default None. + baseobj : object or List() or App::PropertyLinkSubList, optional + Currently unimplemented, it replaces and behaves in the same way as the objects parameter + if defined. By default None. + name : str, optional + The user-facing name to assign to the space object's label. By default None, in + which case the label is set to "Space". + Notes + ----- + The objects parameter can be passed using either of these different formats: + 1. Single object (e.g. a Part::Feature document object). Will be used as the space's base + shape. + :: + objects = + 2. List of selection objects, as provided by ``Gui.Selection.getSelectionEx()``. This + requires the GUI to be active. The `SubObjects` property of each selection object in the + list defines the space's boundaries. If the list contains a single selection object without + subobjects, or with only one subobject, the object in its ``Object`` property is used as + the base shape. + :: + objects = [, ...] + 3. A list of tuples that can be assigned to an ``App::PropertyLinkSubList`` property. Each + tuple contains a document object and a nested tuple of subobjects that define boundaries. If + the list contains a single tuple without a nested subobjects tuple, or a subobjects tuple + with only one subobject, the object in the tuple is used as the base shape. + :: + objects = [(obj1, ("Face1")), (obj2, ("Face1")), ...] + objects = [(obj, ("Face1", "Face2", "Face3", "Face4"))] + """ import ArchSpace if not FreeCAD.ActiveDocument: FreeCAD.Console.PrintError("No active document. Aborting\n") return - obj = FreeCAD.ActiveDocument.addObject("Part::FeaturePython","Space") - obj.Label = name if name else translate("Arch","Space") - ArchSpace._Space(obj) + space = FreeCAD.ActiveDocument.addObject("Part::FeaturePython","Space") + space.Label = name if name else translate("Arch","Space") + ArchSpace._Space(space) if FreeCAD.GuiUp: - ArchSpace._ViewProviderSpace(obj.ViewObject) + ArchSpace._ViewProviderSpace(space.ViewObject) if baseobj: objects = baseobj if objects: if not isinstance(objects,list): objects = [objects] - if len(objects) == 1: - obj.Base = objects[0] - if FreeCAD.GuiUp: - objects[0].ViewObject.hide() + + isSingleObject = lambda objs: len(objs) == 1 + + # We assume that the objects list is not a mixed set. The type of the first + # object will determine the type of the set. + # Input to this function can come into three different formats. First convert it + # to a common format: [ (, ["Face1", ...]), ... ] + if (hasattr(objects[0], "isDerivedFrom") and + objects[0].isDerivedFrom("Gui::SelectionObject")): + # Selection set: convert to common format + # [, ...] + objects = [(obj.Object, obj.SubElementNames) for obj in objects] + elif (isinstance(objects[0], tuple) or isinstance(objects[0], list)): + # Tuple or list of object with subobjects: pass unmodified + # [ (, ["Face1", ...]), ... ] + pass else: - obj.Proxy.addSubobjects(obj,objects) - return obj + # Single object: assume anything else passed is a single object with no + # boundaries. + # [ ] + objects = [(objects[0], [])] + + if isSingleObject(objects): + # For a single object, having boundaries is determined by them being defined + # as more than one subelement (e.g. two faces) + boundaries = [obj for obj in objects if len(obj[1]) > 1] + else: + boundaries = [obj for obj in objects if obj[1]] + + if isSingleObject(objects) and not boundaries: + space.Base = objects[0][0] + if FreeCAD.GuiUp: + objects[0][0].ViewObject.hide() + else: + space.Proxy.addSubobjects(space, boundaries) + return space def makeStairs(baseobj=None,length=None,width=None,height=None,steps=None,name=None): diff --git a/src/Mod/BIM/TestArch.py b/src/Mod/BIM/TestArch.py index c937f7dc50..90222f87e3 100644 --- a/src/Mod/BIM/TestArch.py +++ b/src/Mod/BIM/TestArch.py @@ -818,6 +818,58 @@ class ArchTest(unittest.TestCase): App.ActiveDocument.recompute() assert True + def test_SpaceFromSingleWall(self): + """Create a space from boundaries of a single wall. + """ + from FreeCAD import Units + + operation = "Arch Space from single wall" + _msg(f"\n Test '{operation}'") + + # Create a wall + wallInnerLength = 4000.0 + wallHeight = 3000.0 + wallInnerFaceArea = wallInnerLength * wallHeight + pl = App.Placement() + pl.Rotation.Q = (0.0, 0.0, 0.0, 1.0) + pl.Base = App.Vector(0.0, 0.0, 0.0) + rectangleBase = Draft.make_rectangle( + length=wallInnerLength, height=wallInnerLength, placement=pl, face=True, support=None) + App.ActiveDocument.recompute() # To calculate rectangle area + rectangleArea = rectangleBase.Area + App.ActiveDocument.getObject(rectangleBase.Name).MakeFace = False + wall = Arch.makeWall(baseobj=rectangleBase, height=wallHeight, align="Left") + App.ActiveDocument.recompute() # To calculate face areas + + # Create a space from the wall's inner faces + boundaries = [f"Face{ind+1}" for ind, face in enumerate(wall.Shape.Faces) + if round(face.Area) == round(wallInnerFaceArea)] + + if App.GuiUp: + FreeCADGui.Selection.clearSelection() + FreeCADGui.Selection.addSelection(wall, boundaries) + + space = Arch.makeSpace(FreeCADGui.Selection.getSelectionEx()) + # Alternative, but test takes longer to run (~10x) + # FreeCADGui.activateWorkbench("BIMWorkbench") + # FreeCADGui.runCommand('Arch_Space', 0) + # space = App.ActiveDocument.Space + else: + # Also tests the alternative way of specifying the boundaries + # [ (, ["Face1", ...]), ... ] + space = Arch.makeSpace([(wall, boundaries)]) + + App.ActiveDocument.recompute() # To calculate space area + + # Assert if area is as expected + expectedArea = Units.parseQuantity(str(rectangleArea)) + actualArea = Units.parseQuantity(str(space.Area)) + + self.assertAlmostEqual( + expectedArea.Value, + actualArea.Value, + msg = f"Invalid area value. Expected: {expectedArea.UserString}, actual: {actualArea.UserString}") + def tearDown(self): App.closeDocument("ArchTest") pass diff --git a/src/Mod/BIM/bimcommands/BimSpace.py b/src/Mod/BIM/bimcommands/BimSpace.py index 6fc25ffd7e..a05e39ff0f 100644 --- a/src/Mod/BIM/bimcommands/BimSpace.py +++ b/src/Mod/BIM/bimcommands/BimSpace.py @@ -56,10 +56,7 @@ class Arch_Space: sel = FreeCADGui.Selection.getSelection() if sel: FreeCADGui.Control.closeDialog() - if len(sel) == 1: - FreeCADGui.doCommand("obj = Arch.makeSpace(FreeCADGui.Selection.getSelection())") - else: - FreeCADGui.doCommand("obj = Arch.makeSpace(FreeCADGui.Selection.getSelectionEx())") + FreeCADGui.doCommand("obj = Arch.makeSpace(FreeCADGui.Selection.getSelectionEx())") FreeCADGui.addModule("Draft") FreeCADGui.doCommand("Draft.autogroup(obj)") FreeCAD.ActiveDocument.commitTransaction()