BIM: Support for property sets in Native IFC (#18067)

* BIM: UI to add properties and psets to IFC objects

* BIM: Support native IFC objects in BimProperties

* BIM: Support removing IFC properties

* BIM: Fixed lint issues
This commit is contained in:
Yorik van Havre
2024-12-03 16:08:27 +01:00
committed by GitHub
parent 6a92b77632
commit 41ca58bf7c
11 changed files with 869 additions and 91 deletions

View File

@@ -31,7 +31,7 @@ params = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/NativeIFC")
def add_observer():
"""Adds an observer to the running FreeCAD instance"""
"""Adds this observer to the running FreeCAD instance"""
FreeCAD.BIMobserver = ifc_observer()
FreeCAD.addDocumentObserver(FreeCAD.BIMobserver)
@@ -123,6 +123,11 @@ class ifc_observer:
ifc_status.on_activate()
def slotRemoveDynamicProperty(self, obj, prop):
from nativeifc import ifc_psets
ifc_psets.remove_property(obj, prop)
# implementation methods
def fit_all(self):

View File

@@ -23,10 +23,13 @@
"""This NativeIFC module deals with properties and property sets"""
import os
import re
import FreeCAD
from nativeifc import ifc_tools
translate = FreeCAD.Qt.translate
def has_psets(obj):
"""Returns True if an object has attached psets"""
@@ -148,75 +151,117 @@ def show_psets(obj):
setattr(obj, pname, value)
def edit_pset(obj, prop, value=None):
"""Edits the corresponding property"""
def edit_pset(obj, prop, value=None, force=False):
"""Edits the corresponding property. If force is True,
the property is created even if it has no value"""
pset = obj.getGroupOfProperty(prop)
ttip = obj.getDocumentationOfProperty(prop)
ptype = obj.getDocumentationOfProperty(prop)
if value is None:
value = getattr(obj, prop)
ifcfile = ifc_tools.get_ifcfile(obj)
element = ifc_tools.get_ifc_element(obj)
pset_exist = get_psets(element)
if ttip.startswith("Ifc") and ":" in ttip:
target_prop = ttip.split(":", 1)[-1]
target_prop = None
value_exist = None
# build prop name and type
if ptype.startswith("Ifc"):
if ":" in ptype:
target_prop = ptype.split(":", 1)[-1]
ptype = ptype.split(":", 1)[0]
else:
# no tooltip set - try to build a name
ptype = obj.getTypeIdOfProperty(prop)
if ifcprop == "App::PropertyDistance":
ptype = "IfcLengthMeasure"
elif ifcprop == "App::PropertyLength":
ptype = "IfcPositiveLengthMeasure"
elif ifcprop == "App::PropertyBool":
ptype = "IfcBoolean"
elif ifcprop == "App::PropertyInteger":
ptype = "IfcInteger"
elif ifcprop == "App::PropertyFloat":
ptype = "IfcReal"
elif ifcprop == "App::PropertyArea":
ptype = "IfcAreaMeasure"
else:
# default
ptype = "IfcLabel"
if not target_prop:
# test if the prop exists under different forms (uncameled, unslashed...)
prop = prop.rstrip("_")
prop_uncamel = re.sub(r"(\w)([A-Z])", r"\1 \2", prop)
prop_unslash = re.sub(r"(\w)([A-Z])", r"\1\/\2", prop)
target_prop = None
if pset in pset_exist:
if not target_prop:
if pset in pset_exist:
if prop in pset_exist[pset]:
target_prop = prop
elif prop_uncamel in pset_exist[pset]:
target_prop = prop_uncamel
elif prop_unslash in pset_exist[pset]:
target_prop = prop_unslash
if target_prop:
value_exist = pset_exist[pset][target_prop].split("(", 1)[1][:-1].strip("'")
if value_exist in [".F.", ".U."]:
value_exist = False
elif value_exist in [".T."]:
value_exist = True
elif isinstance(value, int):
value_exist = int(value_exist.strip("."))
elif isinstance(value, float):
value_exist = float(value_exist)
elif isinstance(value, FreeCAD.Units.Quantity):
if value.Unit.Type == "Angle":
value_exist = float(value_exist)
while value_exist > 360:
value_exist = value_exist - 360
value_exist = FreeCAD.Units.Quantity(float(value_exist), value.Unit)
if value == value_exist:
return False
else:
FreeCAD.Console.PrintLog(
"IFC: property changed for "
+ obj.Label
+ " ("
+ str(obj.StepId)
+ ") : "
+ str(target_prop)
+ " : "
+ str(value)
+ " ("
+ str(type(value))
+ ") -> "
+ str(value_exist)
+ " ("
+ str(type(value_exist))
+ ")\n"
)
pset = get_pset(pset, element)
else:
pset = ifc_tools.api_run("pset.add_pset", ifcfile, product=element, name=pset)
if not target_prop:
target_prop = prop
# create pset if needed
if pset in pset_exist:
ifcpset = get_pset(pset, element)
if target_prop in pset_exist[pset]:
value_exist = pset_exist[pset][target_prop].split("(", 1)[1][:-1].strip("'")
else:
ifcpset = ifc_tools.api_run("pset.add_pset", ifcfile, product=element, name=pset)
# value conversions
if value_exist in [".F.", ".U."]:
value_exist = False
elif value_exist in [".T."]:
value_exist = True
elif isinstance(value, int):
if value_exist:
value_exist = int(value_exist.strip("."))
elif isinstance(value, float):
if value_exist:
value_exist = float(value_exist)
elif isinstance(value, FreeCAD.Units.Quantity):
if value_exist:
value_exist = float(value_exist)
if value.Unit.Type == "Angle":
if value_exist:
while value_exist > 360:
value_exist = value_exist - 360
value = value.getValueAs("deg")
elif value.Unit.Type == "Length":
value = value.getValueAs("mm").Value * ifc_tools.get_scale(ifcfile)
else:
print("DEBUG: unhandled quantity type:",value, value.Unit.Type)
return False
if value == value_exist:
return False
if not force and not value and not value_exist:
return False
FreeCAD.Console.PrintLog(
"IFC: property changed for "
+ obj.Label
+ " ("
+ str(obj.StepId)
+ "): "
+ str(target_prop)
+ ": "
+ str(value_exist)
+ " ("
+ type(value_exist).__name__
+ ") -> "
+ str(value)
+ " ("
+ type(value).__name__
+ ")\n"
)
# run the change
# TODO the property type is automatically determined by ifcopenhell
# https://docs.ifcopenshell.org/autoapi/ifcopenshell/api/pset/edit_pset/index.html
# and is therefore wrong for Quantity types. Research a way to overcome that
ifc_tools.api_run(
"pset.edit_pset", ifcfile, pset=pset, properties={target_prop: value}
"pset.edit_pset", ifcfile, pset=ifcpset, properties={target_prop: value}
)
# TODO manage quantities
return True
@@ -252,3 +297,62 @@ def add_property(ifcfile, pset, name, value=""):
To force a certain type, value can also be an IFC element such as IfcLabel"""
ifc_tools.api_run("pset.edit_pset", ifcfile, pset=pset, properties={name: value})
def get_freecad_type(ptype):
"""Returns a FreeCAD property type correspinding to an IFC property type"""
conv = read_properties_conversion()
for key, values in conv.items():
if ptype.lower() in [v.lower() for v in values.split(":")]:
return key
return "App::PropertyString"
def get_ifc_type(fctype):
"""Returns an IFC property type correspinding to a FreeCAD property type"""
conv = read_properties_conversion()
for key, values in conv.items():
if fctype.lower() == key.lower():
return values.split(":")[0]
return "IfcLabel"
def read_properties_conversion():
"""Reads the properties conversion table"""
import csv
csvfile = os.path.join(
FreeCAD.getResourceDir(), "Mod", "BIM", "Presets", "properties_conversion.csv"
)
result = {}
if os.path.exists(csvfile):
with open(csvfile, "r") as f:
reader = csv.reader(f, delimiter=",")
for row in reader:
result[row[0]] = row[1]
return result
def remove_property(obj, prop):
"""Removes a custom property"""
from nativeifc import ifc_tools
ifcfile = ifc_tools.get_ifcfile(obj)
if not ifcfile:
return
element = ifc_tools.get_ifc_element(obj, ifcfile)
if not element:
return
psets = get_psets(element)
for psetname, props in psets.items():
if prop in props:
pset = get_pset(psetname, element)
if pset:
FreeCAD.Console.PrintMessage(translate("BIM","Removing property")+": "+prop)
ifc_tools.api_run("pset.edit_pset", ifcfile, pset=pset, properties={prop: None})
if len(props) == 1:
# delete the pset too
FreeCAD.Console.PrintMessage(translate("BIM","Removing property set")+": "+psetname)
ifc_tools.api_run("pset.remove_pset", ifcfile, product=element, pset=pset)

View File

@@ -24,6 +24,8 @@
"""This contains nativeifc status widgets and functionality"""
import os
import csv
import FreeCAD
import FreeCADGui
@@ -56,6 +58,169 @@ def set_status_widget(statuswidget):
lock_button.setChecked(checked)
on_toggle_lock(checked, noconvert=True)
lock_button.triggered.connect(on_toggle_lock)
set_properties_editor(statuswidget)
def set_properties_editor(statuswidget):
"""Adds additional buttons to the properties editor"""
if hasattr(statuswidget, "propertybuttons"):
statuswidget.propertybuttons.show()
else:
from PySide import QtCore, QtGui # lazy loading
mw = FreeCADGui.getMainWindow()
editor = mw.findChild(QtGui.QTabWidget,"propertyTab")
if editor:
pTabCornerWidget = QtGui.QWidget()
pButton1 = QtGui.QToolButton(pTabCornerWidget)
pButton1.setText("")
pButton1.setToolTip(translate("BIM","Add IFC property..."))
pButton1.setIcon(QtGui.QIcon(":/icons/IFC.svg"))
pButton1.clicked.connect(on_add_property)
pButton2 = QtGui.QToolButton(pTabCornerWidget)
pButton2.setText("")
pButton2.setToolTip(translate("BIM","Add standard IFC Property Set..."))
pButton2.setIcon(QtGui.QIcon(":/icons/BIM_IfcProperties.svg"))
pButton2.clicked.connect(on_add_pset)
pHLayout = QtGui.QHBoxLayout(pTabCornerWidget)
pHLayout.addWidget(pButton1)
pHLayout.addWidget(pButton2)
pHLayout.setSpacing(2)
pHLayout.setContentsMargins(2, 2, 0, 0)
pHLayout.insertStretch(0)
editor.setCornerWidget(pTabCornerWidget, QtCore.Qt.BottomRightCorner)
statuswidget.propertybuttons = pTabCornerWidget
QtCore.QTimer.singleShot(0,pTabCornerWidget.show)
def on_add_property():
"""When the 'add property' button is clicked"""
sel = FreeCADGui.Selection.getSelection()
if not sel:
return
from PySide import QtCore, QtGui # lazy loading
from nativeifc import ifc_psets
obj = sel[0]
psets = list(set([obj.getGroupOfProperty(p) for p in obj.PropertiesList]))
psets = [p for p in psets if p]
psets = [p for p in psets if p not in ["Base", "IFC", "Geometry"]]
mw = FreeCADGui.getMainWindow()
editor = mw.findChild(QtGui.QTabWidget,"propertyTab")
pset = None
if editor:
wid = editor.currentWidget()
if wid and wid.objectName() == "propertyEditorData":
if wid.currentIndex().parent():
pset = wid.currentIndex().parent().data()
else:
pset = wid.currentIndex().data()
form = FreeCADGui.PySideUic.loadUi(":/ui/dialogAddProperty.ui")
# center the dialog over FreeCAD window
form.move(mw.frameGeometry().topLeft() + mw.rect().center() - form.rect().center())
form.field_pset.clear()
form.field_pset.addItems(psets)
if pset and (pset in psets):
form.field_pset.setCurrentIndex(psets.index(pset))
# TODO check for name duplicates while typing
# execute
result = form.exec_()
if not result:
return
pname = form.field_name.text()
if pname in obj.PropertiesList:
print("DEBUG: property already exists",pname)
return
pset = form.field_pset.currentText()
if not pset:
# TODO disable the OK button if empty
t = translate("BIM","No Property set provided")
FreeCAD.Console.PrintError(t+"\n")
ptype = form.field_type.currentIndex()
ptype = ["IfcLabel", "IfcBoolean",
"IfcInteger", "IfcReal",
"IfcLengthMeasure", "IfcAreaMeasure"][ptype]
fctype = ifc_psets.get_freecad_type(ptype)
FreeCAD.ActiveDocument.openTransaction(translate("BIM","add property"))
for obj in sel:
obj.addProperty(fctype, pname, pset, ptype+":"+pname)
ifc_psets.edit_pset(obj, pname, force=True)
FreeCAD.ActiveDocument.commitTransaction()
def on_add_pset():
"""When the 'add pset' button is pressed"""
def read_csv(csvfile):
result = {}
if os.path.exists(csvfile):
with open(csvfile, "r") as f:
reader = csv.reader(f, delimiter=";")
for row in reader:
result[row[0]] = row[1:]
return result
def get_fcprop(ifcprop):
if ifcprop == "IfcLengthMeasure":
return "App::PropertyDistance"
elif ifcprop == "IfcPositiveLengthMeasure":
return "App::PropertyLength"
elif ifcprop in ["IfcBoolean", "IfcLogical"]:
return "App::PropertyBool"
elif ifcprop == "IfcInteger":
return "App::PropertyInteger"
elif ifcprop == "IfcReal":
return "App::PropertyFloat"
elif ifcprop == "IfcAreaMeasure":
return "App::PropertyArea"
return "App::PropertyString"
sel = FreeCADGui.Selection.getSelection()
if not sel:
return
from PySide import QtCore, QtGui # lazy loading
from nativeifc import ifc_psets
obj = sel[0]
mw = FreeCADGui.getMainWindow()
# read standard psets
psetpath = os.path.join(
FreeCAD.getResourceDir(), "Mod", "BIM", "Presets", "pset_definitions.csv"
)
custompath = os.path.join(FreeCAD.getUserAppDataDir(), "BIM", "CustomPsets.csv")
psetdefs = read_csv(psetpath)
psetdefs.update(read_csv(custompath))
psetkeys = list(psetdefs.keys())
psetkeys.sort()
form = FreeCADGui.PySideUic.loadUi(":/ui/dialogAddPSet.ui")
# center the dialog over FreeCAD window
form.move(mw.frameGeometry().topLeft() + mw.rect().center() - form.rect().center())
form.field_pset.clear()
form.field_pset.addItems(psetkeys)
# execute
result = form.exec_()
if not result:
return
pset = form.field_pset.currentText()
existing_psets = list(set([obj.getGroupOfProperty(p) for p in obj.PropertiesList]))
if pset in existing_psets:
t = translate("BIM","Property set already exists")
FreeCAD.Console.PrintError(t+": "+pset+"\n")
return
props = [psetdefs[pset][i:i+2] for i in range(0, len(psetdefs[pset]), 2)]
props = [[p[0], p[1]] for p in props]
FreeCAD.ActiveDocument.openTransaction(translate("BIM","add property set"))
for obj in sel:
existing_psets = list(set([obj.getGroupOfProperty(p) for p in obj.PropertiesList]))
if pset not in existing_psets:
ifc_psets.add_pset(obj, pset)
for prop in props:
if prop[0] in obj.PropertiesList:
t = translate("BIM","Property already exists")
FreeCAD.Console.PrintWarning(t+": "+obj.Label+","+prop[0]+"\n")
else:
obj.addProperty(get_fcprop(prop[1]),prop[0],pset,prop[1]+":"+prop[0])
FreeCAD.ActiveDocument.commitTransaction()
def on_toggle_lock(checked=None, noconvert=False, setchecked=False):

View File

@@ -745,6 +745,21 @@ def set_attribute(ifcfile, element, attribute, value):
# This function can become pure IFC
def differs(val1, val2):
if val1 == val2:
return False
if not val1 and not val2:
return False
if val1 is None and "NOTDEFINED" in str(val2).upper():
return False
if val1 is None and "UNDEFINED" in str(val2).upper():
return False
if val2 is None and "NOTDEFINED" in str(val1).upper():
return False
if val2 is None and "UNDEFINED" in str(val1).upper():
return False
return True
if not ifcfile or not element:
return False
if isinstance(value, FreeCAD.Units.Quantity):
@@ -774,7 +789,7 @@ def set_attribute(ifcfile, element, attribute, value):
):
# do not consider default FreeCAD names given to unnamed alements
return False
if getattr(element, attribute) != value:
if differs(getattr(element, attribute, None),value):
FreeCAD.Console.PrintLog(
"Changing IFC attribute value of "
+ str(attribute)
@@ -802,22 +817,25 @@ def set_colors(obj, colors):
else:
colors = [abs(c) for c in colors]
if hasattr(vobj, "ShapeColor"):
if isinstance(colors[0], (tuple, list)):
vobj.ShapeColor = colors[0][:3]
# do not set transparency when the object has more than one color
#if len(colors[0]) > 3:
# vobj.Transparency = int(colors[0][3] * 100)
else:
vobj.ShapeColor = colors[:3]
if len(colors) > 3:
vobj.Transparency = int(colors[3] * 100)
if hasattr(vobj, "DiffuseColor"):
# strip out transparency value because it currently gives ugly
# results in FreeCAD when combining transparent and non-transparent objects
if all([len(c) > 3 and c[3] != 0 for c in colors]):
vobj.DiffuseColor = colors
else:
vobj.DiffuseColor = [c[:3] for c in colors]
# 1.0 materials
if not isinstance(colors[0], (tuple, list)):
colors = [colors]
# set the first color to opaque otherwise it spoils object transparency
if len(colors) > 1:
#colors[0] = colors[0][:3] + (0.0,)
# TEMP HACK: if multiple colors, set everything to opaque because it looks wrong
colors = [color[:3] + (0.0,) for color in colors]
sapp = []
for color in colors:
sapp_mat = FreeCAD.Material()
if len(color) < 4:
sapp_mat.DiffuseColor = color + (1.0,)
else:
sapp_mat.DiffuseColor = color[:3] + (1.0 - color[3],)
sapp_mat.Transparency = color[3] if len(color) > 3 else 0.0
sapp.append(sapp_mat)
#print(vobj.Object.Label,[[m.DiffuseColor,m.Transparency] for m in sapp])
vobj.ShapeAppearance = sapp
def get_body_context_ids(ifcfile):