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>
This commit is contained in:
Furgo
2026-03-14 12:22:02 +01:00
committed by GitHub
parent 4ac848aab0
commit d1476c819c
11 changed files with 339 additions and 108 deletions
+99 -13
View File
@@ -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: <App::DocumentObject>
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 : <App::DocumentObject>
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)
+18
View File
@@ -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.
+5
View File
@@ -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)
-35
View File
@@ -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
+4
View File
@@ -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)
+1 -23
View File
@@ -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
+4 -36
View File
@@ -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)
+1
View File
@@ -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
+14 -1
View File
@@ -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
+67
View File
@@ -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())
+126
View File
@@ -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"
)