From d1476c819c6e9ec571b1844a7be3eed01aba096a Mon Sep 17 00:00:00 2001 From: Furgo <148809153+furgo16@users.noreply.github.com> Date: Sat, 14 Mar 2026 12:22:02 +0100 Subject: [PATCH] BIM: refactor link processing, add BIM_Link wrapper command (#28104) * BIM: refactor, introduce link properties override helper * BIM: add Link support to Arch_Add, Arch_Remove Fixes: https://github.com/FreeCAD/FreeCAD/issues/26255 Fixes: https://github.com/FreeCAD/FreeCAD/issues/28139 Co-authored-by: Roy-043 <70520633+Roy-043@users.noreply.github.com> * BIM: add link command wrapper * BIM: add link addition and removal tests --------- Co-authored-by: Roy-043 <70520633+Roy-043@users.noreply.github.com> --- src/Mod/BIM/ArchCommands.py | 112 ++++++++++++++++--- src/Mod/BIM/ArchComponent.py | 18 ++++ src/Mod/BIM/ArchCurtainWall.py | 5 + src/Mod/BIM/ArchEquipment.py | 35 ------ src/Mod/BIM/ArchRebar.py | 4 + src/Mod/BIM/ArchSketchObject.py | 24 +---- src/Mod/BIM/ArchWindow.py | 40 +------ src/Mod/BIM/CMakeLists.txt | 1 + src/Mod/BIM/InitGui.py | 15 ++- src/Mod/BIM/bimcommands/BimLink.py | 67 ++++++++++++ src/Mod/BIM/bimtests/TestArchComponent.py | 126 ++++++++++++++++++++++ 11 files changed, 339 insertions(+), 108 deletions(-) create mode 100644 src/Mod/BIM/bimcommands/BimLink.py diff --git a/src/Mod/BIM/ArchCommands.py b/src/Mod/BIM/ArchCommands.py index b26a28ae43..05eca394f1 100644 --- a/src/Mod/BIM/ArchCommands.py +++ b/src/Mod/BIM/ArchCommands.py @@ -57,6 +57,24 @@ else: # module functions ############################################### +def is_type_or_link(obj, type_name): + """Checks if the given object or its linked object is of the specified type. + + Parameters + ---------- + obj: + The object to check. + type_name: str + The type to check against (e.g., 'Window', 'Wall'). + """ + if not obj: + return False + + target = obj.getLinkedObject() if hasattr(obj, "getLinkedObject") else obj + + return Draft.getType(target) == type_name + + def getStringList(objects): """getStringList(objects): returns a string defining a list of objects""" @@ -136,12 +154,10 @@ def addComponents(objectsList, host): x = getattr(host, "Axes", []) for o in objectsList: if hasattr(o, "Shape"): - if Draft.getType(o) == "Window": - if hasattr(o, "Hosts"): - if not host in o.Hosts: - g = o.Hosts - g.append(host) - o.Hosts = g + if is_type_or_link(o, "Window"): + ensure_link_overrides(o) + if hasattr(o, "Hosts") and host not in o.Hosts: + o.Hosts += [host] elif o in outList: FreeCAD.Console.PrintWarning( translate( @@ -206,12 +222,10 @@ def removeComponents(objectsList, host=None): host.Axes = a s = host.Subtractions for o in objectsList: - if Draft.getType(o) == "Window": - if hasattr(o, "Hosts"): - if not host in o.Hosts: - g = o.Hosts - g.append(host) - o.Hosts = g + if is_type_or_link(o, "Window"): + ensure_link_overrides(o) + if hasattr(o, "Hosts") and host not in o.Hosts: + o.Hosts += [host] elif not o in s: s.append(o) if FreeCAD.GuiUp: @@ -281,8 +295,13 @@ def removeComponents(objectsList, host=None): if o in a: a.remove(o) h.Objects = a - if hasattr(o, "Hosts") and Draft.getType(o) == "Window": + if hasattr(o, "Hosts") and is_type_or_link(o, "Window"): + ensure_link_overrides(o) + # Ensure the hosts are recomputed upon window removal + old_hosts = o.Hosts[:] o.Hosts = [] + for old_host in old_hosts: + old_host.touch() def makeComponent(baseobj=None, name=None, delete=False): @@ -1727,3 +1746,70 @@ def makeIfcSpreadsheet(archobj=None): FreeCAD.ActiveDocument.removeObject(ifc_spreadsheet) else: return ifc_spreadsheet + + +def override_link_properties(link_obj, prop_names): + """ + Injects local properties into a Link object to 'shadow' its source's properties. + + By creating a local property, the Link can then to hold instance-specific data (like a Window's + 'Hosts' list) without modifying the source and without triggering the heavy CopyOnChange + deep-copy mechanism of App::Link. + + This function must be called before the Link's first recompute, which is when the core merges + the source's properties into the Link. This is typically achieved by calling it from within the + `appLinkExecute` method of the source object, or by forcefully calling it via + `ensure_link_overrides` if the user interacts with an unrecomputed Link. + + See: https://wiki.freecad.org/Std_LinkMake#Copy_on_Change + + Parameters + ---------- + link_obj : + The newly created Link object (`App::Link`) that will receive the overridden properties. + prop_names : list of str + A list of property names (e.g., `["Hosts"]`) to extract from the source object and + recreate locally on the Link object. + """ + if not link_obj or not prop_names: + return + + # Check if we have a linked object + source_obj = link_obj.LinkedObject + if not source_obj: + return + + for prop_name in prop_names: + # If the property is not in PropertiesList, the Link has not yet merged the source's + # properties (i.e. we're running inside the `appLinkExecute` hook), or the property + # genuinely doesn't exist. + if prop_name not in link_obj.PropertiesList: + + prop_type = source_obj.getTypeIdOfProperty(prop_name) + if not prop_type: # Property doesn't exist on the source + continue + + prop_group = source_obj.getGroupOfProperty(prop_name) + prop_doc = source_obj.getDocumentationOfProperty(prop_name) + + # Inject the shadow property locally on the Link + link_obj.addProperty(prop_type, prop_name, prop_group, prop_doc, locked=True) + + # Initialize with the source's current value when the link is created. Afterwards, the + # user can change the Link's property independently, and it won't affect the source or + # trigger a deep copy. + prop_value = getattr(source_obj, prop_name) + setattr(link_obj, prop_name, prop_value) + + +def ensure_link_overrides(obj): + """ + Ensures that a Link object has its required override properties set up. This acts as a safeguard + if a user modifies a Link before it has been recomputed for the first time (which normally + triggers appLinkExecute). + """ + if obj.isDerivedFrom("App::Link"): + source = obj.LinkedObject + if source and hasattr(source, "Proxy") and hasattr(source.Proxy, "LinkOverrideProperties"): + # Force the override injection on the spot + override_link_properties(obj, source.Proxy.LinkOverrideProperties) diff --git a/src/Mod/BIM/ArchComponent.py b/src/Mod/BIM/ArchComponent.py index f505328645..24206c756b 100644 --- a/src/Mod/BIM/ArchComponent.py +++ b/src/Mod/BIM/ArchComponent.py @@ -201,6 +201,10 @@ class Component(ArchIFC.IfcProduct): The object to turn into an Arch Component """ + # List of properties to override in App::Link. + # Subclasses which require overrides (e.g. Window, Rebar) should populate the overrides list. + LinkOverrideProperties = [] + def __init__(self, obj): obj.Proxy = self self.Type = "Component" @@ -1326,6 +1330,20 @@ class Component(ArchIFC.IfcProduct): return True return False + def appLinkExecute(self, obj, linkObj, index, linkElement): + """ + App::Link hook: called when a link to a BIM object is created. + Used to setup shadow properties for lightweight instancing. + """ + # Shadow the given property so multiple links can have independent values without triggering + # a deep copy of the BIM object geometry. + if self.LinkOverrideProperties: + ArchCommands.override_link_properties(linkObj, self.LinkOverrideProperties) + + # Execute features in the SketchArch External Add-on, if present + if hasattr(self, "executeSketchArchFeatures"): + self.executeSketchArchFeatures(obj, linkObj, index, linkElement) + class AreaCalculator: """Helper class to compute vertical area, horizontal area, and perimeter length. diff --git a/src/Mod/BIM/ArchCurtainWall.py b/src/Mod/BIM/ArchCurtainWall.py index 9046ba4093..3dc14f19a1 100644 --- a/src/Mod/BIM/ArchCurtainWall.py +++ b/src/Mod/BIM/ArchCurtainWall.py @@ -79,6 +79,11 @@ ANGLETOLERANCE = 0.67 # vectors with angles below this are considered going in class CurtainWall(ArchComponent.Component): "The curtain wall object" + # Configure App::Link shadowing, so that linked curtain walls can have independent Host + # properties without triggering a deep copy of the geometry. See + # ArchComponent.Component.appLinkExecute() + LinkOverrideProperties = ["Host"] + def __init__(self, obj): ArchComponent.Component.__init__(self, obj) diff --git a/src/Mod/BIM/ArchEquipment.py b/src/Mod/BIM/ArchEquipment.py index 35b197a6a7..b7d62e2f80 100644 --- a/src/Mod/BIM/ArchEquipment.py +++ b/src/Mod/BIM/ArchEquipment.py @@ -85,25 +85,6 @@ class _Equipment(ArchComponent.Component): obj.IfcType = "Furnishing Element" else: obj.IfcType = "Building Element Proxy" - # Add features in the SketchArch External Add-on, if present - self.addSketchArchFeatures(obj) - - def addSketchArchFeatures(self, obj, linkObj=None, mode=None): - """ - To add features in the SketchArch External Add-on, if present (https://github.com/paullee0/FreeCAD_SketchArch) - - import ArchSketchObject module, and - - set properties that are common to ArchObjects (including Links) and ArchSketch - to support the additional features - - To install SketchArch External Add-on, see https://github.com/paullee0/FreeCAD_SketchArch#iv-install - """ - - try: - import ArchSketchObject - - ArchSketchObject.ArchSketch.setPropertiesLinkCommon(self, obj, linkObj, mode) - except: - pass def setProperties(self, obj): @@ -159,9 +140,6 @@ class _Equipment(ArchComponent.Component): ArchComponent.Component.onDocumentRestored(self, obj) self.setProperties(obj) - # Add features in the SketchArch External Add-on, if present - self.addSketchArchFeatures(obj) - def loads(self, state): self.Type = "Equipment" @@ -208,19 +186,6 @@ class _Equipment(ArchComponent.Component): except: pass - def appLinkExecute(self, obj, linkObj, index, linkElement): - """ - Default Link Execute method() - - See https://forum.freecad.org/viewtopic.php?f=22&t=42184&start=10#p361124 - @realthunder added support to Links to run Linked Scripted Object's methods() - """ - - # Add features in the SketchArch External Add-on, if present - self.addSketchArchFeatures(obj, linkObj) - - # Execute features in the SketchArch External Add-on, if present - self.executeSketchArchFeatures(obj, linkObj) - def computeAreas(self, obj): return diff --git a/src/Mod/BIM/ArchRebar.py b/src/Mod/BIM/ArchRebar.py index 2d7c751686..acefa4af93 100644 --- a/src/Mod/BIM/ArchRebar.py +++ b/src/Mod/BIM/ArchRebar.py @@ -70,6 +70,10 @@ else: class _Rebar(ArchComponent.Component): "A parametric reinforcement bar (rebar) object" + # Configure App::Link shadowing, so that linked rebars can have independent Host properties + # without triggering a deep copy of the geometry. See ArchComponent.Component.appLinkExecute() + LinkOverrideProperties = ["Host"] + def __init__(self, obj): ArchComponent.Component.__init__(self, obj) diff --git a/src/Mod/BIM/ArchSketchObject.py b/src/Mod/BIM/ArchSketchObject.py index d69eacf99a..489b067251 100644 --- a/src/Mod/BIM/ArchSketchObject.py +++ b/src/Mod/BIM/ArchSketchObject.py @@ -22,7 +22,6 @@ # * * # *************************************************************************** -import ArchWindow from PySide.QtCore import QT_TRANSLATE_NOOP @@ -36,28 +35,7 @@ class ArchSketch(ArchSketchObject): pass def setPropertiesLinkCommon(self, orgFp, linkFp=None, mode=None): - if linkFp: - fp = linkFp - else: - fp = orgFp - prop = fp.PropertiesList - if not isinstance(fp.getLinkedObject().Proxy, ArchWindow._Window): - pass - else: - if "Hosts" not in prop: - # inherited properties of Link are not in PropertiesList: - old_hosts = getattr(fp, "Hosts", []) - fp.addProperty( - "App::PropertyLinkList", - "Hosts", - "Window", - QT_TRANSLATE_NOOP("App::Property", "The objects that host this window"), - locked=True, - ) - fp.Hosts = old_hosts - for host in old_hosts: - host.touch() - # Arch Window's code + pass # from ArchSketchObjectExt import ArchSketch # Doesn't work diff --git a/src/Mod/BIM/ArchWindow.py b/src/Mod/BIM/ArchWindow.py index 0b7a64deef..58bbefe903 100644 --- a/src/Mod/BIM/ArchWindow.py +++ b/src/Mod/BIM/ArchWindow.py @@ -105,6 +105,10 @@ def recolorize(attr): # names is [docname,objname] class _Window(ArchComponent.Component): "The Window object" + # Configure App::Link shadowing, so that linked windows can have independent Hosts properties + # without triggering a deep copy of the geometry. See ArchComponent.Component.appLinkExecute() + LinkOverrideProperties = ["Hosts"] + def __init__(self, obj): ArchComponent.Component.__init__(self, obj) @@ -113,26 +117,6 @@ class _Window(ArchComponent.Component): obj.IfcType = "Window" obj.MoveWithHost = True - # Add features in the SketchArch External Add-on - self.addSketchArchFeatures(obj) - - def addSketchArchFeatures(self, obj, linkObj=None, mode=None): - """ - To add features in the SketchArch External Add-on (https://github.com/paullee0/FreeCAD_SketchArch) - - import ArchSketchObject module, and - - set properties that are common to ArchObjects (including Links) and ArchSketch - to support the additional features - - To install SketchArch External Add-on, see https://github.com/paullee0/FreeCAD_SketchArch#iv-install - """ - - try: - import ArchSketchObject - - ArchSketchObject.ArchSketch.setPropertiesLinkCommon(self, obj, linkObj, mode) - except: - pass - def setProperties(self, obj, mode=None): lp = obj.PropertiesList @@ -313,9 +297,6 @@ class _Window(ArchComponent.Component): ArchComponent.Component.onDocumentRestored(self, obj) self.setProperties(obj, mode="ODR") - # Add features in the SketchArch External Add-on - self.addSketchArchFeatures(obj, mode="ODR") - # During the v1.1 dev cycle an experiment with a new SillHeight handling was # undertaken. This did not work out as intended and was therefore reverted. # Related PRs: @@ -752,19 +733,6 @@ class _Window(ArchComponent.Component): except: pass - def appLinkExecute(self, obj, linkObj, index, linkElement): - """ - Default Link Execute method() - - See https://forum.freecad.org/viewtopic.php?f=22&t=42184&start=10#p361124 - @realthunder added support to Links to run Linked Scripted Object's methods() - """ - - # Add features in the SketchArch External Add-on - self.addSketchArchFeatures(obj, linkObj) - - # Execute features in the SketchArch External Add-on - self.executeSketchArchFeatures(obj, linkObj) - def getSubFace(self): "returns a subface for creation of subvolume for cutting in a base object" # creation of subface from HoleWire (getSubWire) diff --git a/src/Mod/BIM/CMakeLists.txt b/src/Mod/BIM/CMakeLists.txt index 90e2a21824..ba6819bfa1 100644 --- a/src/Mod/BIM/CMakeLists.txt +++ b/src/Mod/BIM/CMakeLists.txt @@ -146,6 +146,7 @@ SET(bimcommands_SRCS bimcommands/BimLayers.py bimcommands/BimLeader.py bimcommands/BimLibrary.py + bimcommands/BimLink.py bimcommands/BimMaterial.py bimcommands/BimMoveView.py bimcommands/BimNudge.py diff --git a/src/Mod/BIM/InitGui.py b/src/Mod/BIM/InitGui.py index 23f0d7e158..6e8b47738a 100644 --- a/src/Mod/BIM/InitGui.py +++ b/src/Mod/BIM/InitGui.py @@ -150,7 +150,7 @@ class BIMWorkbench(Workbench): "Draft_Rotate", "Draft_Scale", "Draft_Mirror", - "BIM_Clone", + "BIM_CloneTools", "BIM_Copy", "BIM_SimpleCopy", "BIM_Compound", @@ -358,6 +358,18 @@ class BIMWorkbench(Workbench): def IsActive(self): return hasattr(FreeCADGui.getMainWindow().getActiveWindow(), "getSceneGraph") + class BIM_CloneTools: + def GetCommands(self): + return ("BIM_Clone", "BIM_LinkMake") + + def GetResources(self): + label = QT_TRANSLATE_NOOP("BIM_CloneTools", "Cloning Tools") + tooltip = label + return {"MenuText": label, "ToolTip": tooltip, "Icon": "BIM_Clone"} + + def IsActive(self): + return hasattr(FreeCADGui.getMainWindow().getActiveWindow(), "getSceneGraph") + # create generic tools command class BIM_GenericTools: def __init__(self, tools): @@ -401,6 +413,7 @@ class BIMWorkbench(Workbench): FreeCADGui.addCommand("BIM_ReportTools", BIM_ReportTools()) FreeCADGui.addCommand("BIM_GenericTools", BIM_GenericTools(self.generictools)) FreeCADGui.addCommand("BIM_Create2DViews", BIM_Create2DViews(self.create_2dviews)) + FreeCADGui.addCommand("BIM_CloneTools", BIM_CloneTools()) # workaround for issue #26539: create draftingtools list without grouped commands # https://github.com/FreeCAD/FreeCAD/issues/26539 diff --git a/src/Mod/BIM/bimcommands/BimLink.py b/src/Mod/BIM/bimcommands/BimLink.py new file mode 100644 index 0000000000..f734c4332f --- /dev/null +++ b/src/Mod/BIM/bimcommands/BimLink.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later + +import FreeCAD +import FreeCADGui + +QT_TRANSLATE_NOOP = FreeCAD.Qt.QT_TRANSLATE_NOOP +translate = FreeCAD.Qt.translate + + +class BIM_LinkMake: + def GetResources(self): + return { + "Pixmap": "Link", + "MenuText": QT_TRANSLATE_NOOP("BIM_LinkMake", "Make Link"), + "ToolTip": QT_TRANSLATE_NOOP( + "BIM_LinkMake", + "Creates a Link to the selected object and immediately enables moving it", + ), + "Accel": "L, K", + } + + def IsActive(self): + return not FreeCAD.ActiveDocument is None + + def Activated(self): + from draftutils.todo import ToDo + + sel = FreeCADGui.Selection.getSelection() + if not sel: + FreeCAD.Console.PrintError(translate("BIM", "Select an object to link") + "\n") + return + + doc = FreeCAD.ActiveDocument + doc.openTransaction("Create BIM Link") + + new_links = [] + + try: + for obj in sel: + # Create the native Link + lnk = doc.addObject("App::Link", obj.Label + "_Link") + lnk.LinkedObject = obj + + # We do not manipulate LinkCopyOnChange here. + # The 'appLinkExecute' hook in the object's proxy will handle the injection of + # shadow properties (like Hosts) to ensure the link remains lightweight. + + new_links.append(lnk) + + doc.commitTransaction() + doc.recompute() + + # Enter Move mode + if new_links: + FreeCADGui.Selection.clearSelection() + for lnk in new_links: + FreeCADGui.Selection.addSelection(lnk) + + # Defer the Move command to ensure the document is stable + ToDo.delay(FreeCADGui.runCommand, "Draft_Move") + + except Exception as e: + FreeCAD.Console.PrintError(f"BIM Link creation failed: {e}\n") + doc.abortTransaction() + + +FreeCADGui.addCommand("BIM_LinkMake", BIM_LinkMake()) diff --git a/src/Mod/BIM/bimtests/TestArchComponent.py b/src/Mod/BIM/bimtests/TestArchComponent.py index 4c298a7547..3b0e965f87 100644 --- a/src/Mod/BIM/bimtests/TestArchComponent.py +++ b/src/Mod/BIM/bimtests/TestArchComponent.py @@ -718,3 +718,129 @@ class TestArchComponent(TestArchBase.TestArchBase): self.assertTrue( comp.Shape.isNull(), "Component retained a stale shape after its Base was removed." ) + + def test_add_window_link_standard(self): + """Test adding a Window Link to a Wall using standard lifecycle (recompute before add). + This verifies the 'appLinkExecute' hook mechanism. + """ + operation = "Arch Link addition (Standard Lifecycle)" + self.printTestMessage(operation) + + # Create the host wall + line = Draft.makeLine(App.Vector(0, 0, 0), App.Vector(4000, 0, 0)) + wall = Arch.makeWall(line, width=200, height=3000, align="Center") + self.document.recompute() + initial_volume = wall.Shape.Volume + + # Create a prototype window + rect = Draft.makeRectangle(length=1000, height=1500) + rect.Placement.Rotation = App.Rotation(App.Vector(1, 0, 0), 90) + rect.Placement.Base = App.Vector(0, 0, 0) + self.document.recompute() + + win_proto = Arch.makeWindow(baseobj=rect, name="Window_Prototype") + win_proto.Width = 1000 + win_proto.Height = 1500 + win_proto.Frame = 100 + self.document.recompute() + + # Create a link to the prototype window and recompute + # This is the standard lifecycle + link1 = self.document.addObject("App::Link", "Link_Standard") + link1.LinkedObject = win_proto + link1.Placement.Base = App.Vector(1000, 0, 500) + self.document.recompute() # Trigger appLinkExecute -> shadow_link_properties + + # Add the window link to the wall + Arch.addComponents(link1, wall) + self.document.recompute() + + # Assert + self.assertIn(wall, link1.Hosts, "Link should host the wall") + self.assertNotIn(wall, win_proto.Hosts, "Prototype should not host the wall") + self.assertLess(wall.Shape.Volume, initial_volume, "Wall volume should decrease (hole cut)") + + def test_add_window_link_immediate(self): + """Test adding a Window Link to a Wall immediately without recomputing. + This verifies the 'ensure_link_overrides' safeguard mechanism. + """ + operation = "Arch Link addition (Immediate Lifecycle)" + self.printTestMessage(operation) + + # Create the host wall + line = Draft.makeLine(App.Vector(0, 0, 0), App.Vector(4000, 0, 0)) + wall = Arch.makeWall(line, width=200, height=3000, align="Center") + self.document.recompute() + initial_volume = wall.Shape.Volume + + # Create a prototype window + rect = Draft.makeRectangle(length=1000, height=1500) + rect.Placement.Rotation = App.Rotation(App.Vector(1, 0, 0), 90) + rect.Placement.Base = App.Vector(0, 0, 0) + self.document.recompute() + + win_proto = Arch.makeWindow(baseobj=rect, name="Window_Prototype") + win_proto.Width = 1000 + win_proto.Height = 1500 + win_proto.Frame = 100 + self.document.recompute() + + # Create a link to the prototype window without recomputing + link2 = self.document.addObject("App::Link", "Link_Immediate") + link2.LinkedObject = win_proto + link2.Placement.Base = App.Vector(3000, 0, 500) + + # Add the window link to the wall immediately + # This triggers ensure_link_overrides -> shadow_link_properties + Arch.addComponents(link2, wall) + self.document.recompute() + + # Assert + self.assertIn(wall, link2.Hosts, "Link should host the wall") + self.assertNotIn(wall, win_proto.Hosts, "Prototype should NOT host the wall") + self.assertLess(wall.Shape.Volume, initial_volume, "Wall volume should decrease (hole cut)") + + def test_remove_window_link(self): + """Test removing a Window Link from a Wall.""" + operation = "Arch Link removal" + self.printTestMessage(operation) + + # Create the host wall + line = Draft.makeLine(App.Vector(0, 0, 0), App.Vector(4000, 0, 0)) + wall = Arch.makeWall(line, width=200, height=3000, align="Center") + self.document.recompute() + initial_volume = wall.Shape.Volume + + # Create a prototype window + rect = Draft.makeRectangle(length=1000, height=1500) + rect.Placement.Rotation = App.Rotation(App.Vector(1, 0, 0), 90) + rect.Placement.Base = App.Vector(0, 0, 0) + self.document.recompute() + + win_proto = Arch.makeWindow(baseobj=rect) + win_proto.Width = 1000 + win_proto.Height = 1500 + win_proto.Frame = 100 + self.document.recompute() + + # Create a link to the prototype window + link = self.document.addObject("App::Link", "Link_Remove") + link.LinkedObject = win_proto + link.Placement.Base = App.Vector(2000, 0, 500) + self.document.recompute() + + # Add the window link to the wall, ensure it cuts the hole + Arch.addComponents(link, wall) + self.document.recompute() + cut_volume = wall.Shape.Volume + self.assertLess(cut_volume, initial_volume, "Setup failed: Wall not cut") + + # Remove the window link from the wall + Arch.removeComponents([link]) + self.document.recompute() + + # Assert + self.assertNotIn(wall, link.Hosts, "Link should no longer host the wall") + self.assertAlmostEqual( + wall.Shape.Volume, initial_volume, 3, "Wall volume should be restored" + )