Skip to content

Commit 3f0efa2

Browse files
author
bonsaiPR automation
committed
Merge remote-tracking branch 'pr-8598/fix-4047-bounded-property-support' into Frankenstein_build-0.9.0-alpha2610050136
2 parents 2d1fd7f + 3480524 commit 3f0efa2

7 files changed

Lines changed: 299 additions & 2 deletions

File tree

‎src/bonsai/bonsai/bim/module/pset/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
operator.BIM_OT_add_edit_custom_property,
4040
operator.BIM_OT_bulk_remove_psets,
4141
prop.IfcPropertyEnumeratedValue,
42+
prop.IfcPropertyBoundedValue,
4243
prop.IfcProperty,
4344
prop.PsetProperties,
4445
prop.RenamePropertyEntry,

‎src/bonsai/bonsai/bim/module/pset/operator.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ def _execute(self, context):
164164
elif prop.value_type == "IfcPropertyEnumeratedValue":
165165
value_name = metadata.get_value_name()
166166
value = [e[value_name] for e in prop.enumerated_value.enumerated_values if e.is_selected]
167+
elif prop.value_type == "IfcPropertyBoundedValue":
168+
properties[metadata.name] = tool.Pset.get_bounded_value_dict(prop) or None
169+
continue
167170
else:
168171
continue
169172
# None (a purge/skip-creation signal, handled by edit_pset/edit_qto before any
@@ -320,6 +323,8 @@ def _execute(self, context):
320323
elif prop.value_type == "IfcPropertyEnumeratedValue":
321324
value_name = prop.metadata.get_value_name()
322325
prop_value = [e[value_name] for e in prop.enumerated_value.enumerated_values if e.is_selected]
326+
elif prop.value_type == "IfcPropertyBoundedValue":
327+
prop_value = tool.Pset.get_bounded_value_dict(prop) or None
323328
else:
324329
self.report({"ERROR"}, f"Unsupport value type: '{prop.value_type}'.")
325330
return {"CANCELLED"}

‎src/bonsai/bonsai/bim/module/pset/prop.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,18 +268,39 @@ class IfcPropertyEnumeratedValue(PropertyGroup):
268268
enumerated_values: bpy.types.bpy_prop_collection_idprop[Attribute]
269269

270270

271-
IfcPropertyValueType = Literal["IfcPropertySingleValue", "IfcPropertyEnumeratedValue"]
271+
class IfcPropertyBoundedValue(PropertyGroup):
272+
lower_bound_value: PointerProperty(type=Attribute)
273+
upper_bound_value: PointerProperty(type=Attribute)
274+
set_point_value: PointerProperty(type=Attribute)
275+
276+
if TYPE_CHECKING:
277+
lower_bound_value: Attribute
278+
upper_bound_value: Attribute
279+
set_point_value: Attribute
280+
281+
282+
#: Maps IfcPropertyBoundedValue Blender attribute names to the IFC attribute they represent.
283+
BOUNDED_VALUE_ATTRS: tuple[tuple[str, str], ...] = (
284+
("lower_bound_value", "LowerBoundValue"),
285+
("upper_bound_value", "UpperBoundValue"),
286+
("set_point_value", "SetPointValue"),
287+
)
288+
289+
290+
IfcPropertyValueType = Literal["IfcPropertySingleValue", "IfcPropertyEnumeratedValue", "IfcPropertyBoundedValue"]
272291

273292

274293
class IfcProperty(PropertyGroup):
275294
metadata: PointerProperty(type=Attribute)
276295
value_type: EnumProperty(items=[(v, v, v) for v in get_args(IfcPropertyValueType)], name="Value Type")
277296
enumerated_value: PointerProperty(type=IfcPropertyEnumeratedValue)
297+
bounded_value: PointerProperty(type=IfcPropertyBoundedValue)
278298

279299
if TYPE_CHECKING:
280300
metadata: Attribute
281301
value_type: IfcPropertyValueType
282302
enumerated_value: IfcPropertyEnumeratedValue
303+
bounded_value: IfcPropertyBoundedValue
283304

284305

285306
class PsetProperties(PropertyGroup):

‎src/bonsai/bonsai/bim/module/pset/ui.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ def draw_property(prop: IfcProperty, layout: bpy.types.UILayout, copy_operator:
5050
draw_single_property(prop, layout, copy_operator)
5151
elif prop.value_type == "IfcPropertyEnumeratedValue":
5252
draw_enumerated_property(prop, layout, copy_operator)
53+
elif prop.value_type == "IfcPropertyBoundedValue":
54+
draw_bounded_property(prop, layout, copy_operator)
5355
else:
5456
assert_never(prop.value_type)
5557

@@ -95,6 +97,24 @@ def draw_enumerated_property(
9597
op.name = prop.metadata.name
9698

9799

100+
def draw_bounded_property(prop: IfcProperty, layout: bpy.types.UILayout, copy_operator: Optional[str] = None) -> None:
101+
layout.label(text=prop.metadata.name)
102+
grid = layout.column_flow(columns=3)
103+
for attr, label in (
104+
(prop.bounded_value.lower_bound_value, "Lower"),
105+
(prop.bounded_value.upper_bound_value, "Upper"),
106+
(prop.bounded_value.set_point_value, "Set Point"),
107+
):
108+
if not (value_name := attr.get_value_name(display_only=True)):
109+
continue
110+
row = grid.row(align=True)
111+
row.prop(attr, value_name, text=label)
112+
row.prop(attr, "is_null", icon="RADIOBUT_OFF" if attr.is_null else "RADIOBUT_ON", text="")
113+
if copy_operator:
114+
op = layout.operator(f"{copy_operator}", text="", icon="COPYDOWN")
115+
op.name = prop.metadata.name
116+
117+
98118
def get_active_pset_obj_name(context: bpy.types.Context, obj_type: tool.Ifc.OBJECT_TYPE) -> str:
99119
if obj_type in ("Object", "MaterialSetItem"):
100120
return context.active_object.name

‎src/bonsai/bonsai/tool/pset.py‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
AddEditPropertyEntry,
3939
DeletePsetEntry,
4040
GlobalPsetProperties,
41+
IfcProperty,
4142
PsetProperties,
4243
RenamePropertyEntry,
4344
)
@@ -49,6 +50,14 @@ class Pset(bonsai.core.tool.Pset):
4950
BulkOperationType = Literal["ADD_EDIT", "RENAME", "DELETE"]
5051
BULK_OPERATION_TYPES = ("ADD_EDIT", "RENAME", "DELETE")
5152

53+
#: Bounded value Blender attribute -> IFC attribute. Duplicates `pset.prop.BOUNDED_VALUE_ATTRS` to avoid a
54+
#: circular import.
55+
BOUNDED_VALUE_ATTRS: tuple[tuple[str, str], ...] = (
56+
("lower_bound_value", "LowerBoundValue"),
57+
("upper_bound_value", "UpperBoundValue"),
58+
("set_point_value", "SetPointValue"),
59+
)
60+
5261
@classmethod
5362
def get_global_pset_props(cls) -> GlobalPsetProperties:
5463
return bpy.context.scene.GlobalPsetProperties
@@ -385,6 +394,16 @@ def process_prop_description(metadata: Attribute) -> None:
385394
new = simple_prop.enumerated_value.enumerated_values.add()
386395
setattr(new, data_type, enum)
387396
new.is_selected = enum in selected_enum_items
397+
elif prop.is_a("IfcPropertyBoundedValue"):
398+
bounded_prop = props.properties.add()
399+
bounded_prop.name = prop.Name
400+
bounded_prop.value_type = "IfcPropertyBoundedValue"
401+
metadata = bounded_prop.metadata
402+
metadata.name = prop.Name
403+
metadata.is_null = True
404+
metadata.is_optional = True
405+
process_prop_description(metadata)
406+
cls.set_bounded_value_attributes(bounded_prop, prop)
388407
else:
389408
if prop.is_a("IfcPropertySingleValue"):
390409
value = prop.NominalValue.wrappedValue if prop.NominalValue else None
@@ -425,6 +444,69 @@ def process_prop_description(metadata: Attribute) -> None:
425444
if str_value is not None:
426445
metadata.enum_value = str_value
427446

447+
@classmethod
448+
def get_bounded_value_dict(cls, prop: IfcProperty) -> dict[str, Any]:
449+
"""Builds the dict expected by ``ifcopenshell.api.pset.edit_pset`` for a bounded value
450+
property row, containing only the sub-fields the user hasn't marked as null."""
451+
return {
452+
ifc_attribute: attr.get_value()
453+
for attr_name, ifc_attribute in cls.BOUNDED_VALUE_ATTRS
454+
if not (attr := getattr(prop.bounded_value, attr_name)).is_null
455+
}
456+
457+
@classmethod
458+
def set_bounded_value_attributes(
459+
cls,
460+
bounded_prop: IfcProperty,
461+
prop: Union[ifcopenshell.entity_instance, None] = None,
462+
data_type: Union[str, None] = None,
463+
) -> None:
464+
"""Populates the Lower/Upper/SetPoint Attribute sub-fields of a bounded value property row.
465+
466+
:param prop: An existing ``IfcPropertyBoundedValue`` to read values from, if any.
467+
:param data_type: Fallback Blender data type (e.g. ``"float"``) used for sub-fields that
468+
have no existing value yet, e.g. when proposing a still-empty property from a template.
469+
"""
470+
resolved_data_type = data_type
471+
if prop is not None and resolved_data_type is None:
472+
for _, ifc_attribute in cls.BOUNDED_VALUE_ATTRS:
473+
if (value := getattr(prop, ifc_attribute, None)) is not None:
474+
resolved_data_type = ifcopenshell.util.attribute.get_primitive_type(
475+
tool.Ifc.schema().declaration_by_name(value.is_a())
476+
)
477+
break
478+
resolved_data_type = resolved_data_type or "float"
479+
480+
for attr_name, ifc_attribute in cls.BOUNDED_VALUE_ATTRS:
481+
attr = getattr(bounded_prop.bounded_value, attr_name)
482+
attr.name = ifc_attribute
483+
attr.is_optional = True
484+
value = getattr(prop, ifc_attribute, None) if prop is not None else None
485+
if value is not None:
486+
attr.set_value(value.wrappedValue)
487+
attr.is_null = False
488+
else:
489+
attr.data_type = resolved_data_type
490+
attr.is_null = True
491+
492+
@classmethod
493+
def import_bounded_value_from_template(
494+
cls,
495+
pset_template: ifcopenshell.entity_instance,
496+
prop_template: ifcopenshell.entity_instance,
497+
props: PsetProperties,
498+
) -> None:
499+
prop = props.properties.add()
500+
prop.name = prop_template.Name
501+
prop.value_type = "IfcPropertyBoundedValue"
502+
metadata = prop.metadata
503+
metadata.name = prop_template.Name
504+
metadata.is_null = True
505+
metadata.is_optional = True
506+
metadata.ifc_class = pset_template.Name
507+
bonsai.bim.helper.add_attribute_description(metadata, prop_template)
508+
cls.set_bounded_value_attributes(prop, data_type=cls.get_prop_template_primitive_type(prop_template))
509+
428510
@classmethod
429511
def get_prop_template_primitive_type(cls, prop_template: ifcopenshell.entity_instance) -> str:
430512
if prop_template.TemplateType in ["Q_LENGTH", "Q_AREA", "Q_VOLUME", "Q_WEIGHT", "Q_TIME"]:
@@ -557,9 +639,15 @@ def import_pset_from_template(
557639
continue
558640
cls.import_enumerated_value_from_template(prop_template, simplified_data, props)
559641

642+
elif prop_template.TemplateType == "P_BOUNDEDVALUE":
643+
if prop_data:
644+
# Existing property (of any class) will be added by import_pset_from_existing,
645+
# which can read all of LowerBoundValue/UpperBoundValue/SetPointValue directly.
646+
continue
647+
cls.import_bounded_value_from_template(pset_template, prop_template, props)
648+
560649
else:
561650
# NOTE: currently unsupported types:
562-
# - P_BOUNDEDVALUE
563651
# - P_LISTVALUE
564652
# - P_REFERENCEVALUE
565653
# - P_TABLEVALUE

‎src/ifcopenshell-python/ifcopenshell/api/pset/edit_pset.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
import ifcopenshell.util.element
2424
import ifcopenshell.util.pset
2525

26+
#: A dict value containing any of these keys describes an ``IfcPropertyBoundedValue``.
27+
BOUNDED_VALUE_KEYS = ("LowerBoundValue", "UpperBoundValue", "SetPointValue")
2628
# Sentinel distinguishing "no Unit dict was passed at all" from "a Unit dict was passed
2729
# with Unit explicitly set to None" (i.e. explicitly clear an existing override).
2830
_NO_UNIT = object()
@@ -77,6 +79,11 @@ def edit_pset(
7779
become IfcBoolean, and integers will become IfcInteger. If more
7880
control is desired, you may explicitly specify IFC data objects
7981
directly. Note that provided `properties` might be mutated in the process.
82+
An ``IfcPropertyBoundedValue`` may be added or edited by providing a
83+
dictionary containing any of the keys ``LowerBoundValue``,
84+
``UpperBoundValue``, or ``SetPointValue`` (at least one is required).
85+
Values may be plain Python types (cast using the property set
86+
template, if available) or explicit IFC data objects.
8087
:param pset_template: If a property set template is provided, this will
8188
be used to determine data types. If no user-defined template is
8289
provided, the built-in buildingSMART templates will be loaded.
@@ -154,6 +161,14 @@ def edit_pset(
154161
# Editing existing properties will retain their current data types
155162
# if possible. So this will still be a length measure.
156163
ifcopenshell.api.pset.edit_pset(model, pset=pset, properties={"ExplicitLength": 12.3})
164+
165+
# Bounded values (IfcPropertyBoundedValue) are described using a
166+
# dictionary with LowerBoundValue / UpperBoundValue / SetPointValue keys.
167+
# Any subset of these keys may be provided, and existing values are
168+
# retained unless a new dictionary is passed.
169+
pset = ifcopenshell.api.pset.add_pset(model, product=wall_type, name="Pset_ElectricalDeviceCommon")
170+
ifcopenshell.api.pset.edit_pset(model, pset=pset,
171+
properties={"RatedCurrent": {"LowerBoundValue": 1.0, "UpperBoundValue": 2.0}})
157172
"""
158173
usecase = Usecase()
159174
usecase.file = file
@@ -226,6 +241,10 @@ def update_existing_properties(self) -> list[ifcopenshell.entity_instance]:
226241
prop = self.update_existing_prop_enum(prop)
227242
if prop:
228243
existing_props.append(prop)
244+
elif prop.is_a("IfcPropertyBoundedValue"):
245+
prop = self.update_existing_prop_bounded_value(prop)
246+
if prop:
247+
existing_props.append(prop)
229248
elif prop.is_a("IfcPropertySingleValue"):
230249
prop = self.update_existing_prop_single_value(prop)
231250
if prop:
@@ -298,6 +317,56 @@ def update_existing_prop_enum(
298317
del self.settings["properties"][prop.Name]
299318
return prop
300319

320+
def update_existing_prop_bounded_value(
321+
self, prop: ifcopenshell.entity_instance
322+
) -> Union[ifcopenshell.entity_instance, None]:
323+
"""
324+
NOTE: Assumes the prop exists
325+
"""
326+
value = self.settings["properties"][prop.Name]
327+
328+
if value is None:
329+
if self._try_purge(prop):
330+
return
331+
for ifc_attribute in BOUNDED_VALUE_KEYS:
332+
setattr(prop, ifc_attribute, None)
333+
elif isinstance(value, dict) and any(k in value for k in BOUNDED_VALUE_KEYS):
334+
primary_measure_type = None
335+
for ifc_attribute in BOUNDED_VALUE_KEYS:
336+
if existing_value := getattr(prop, ifc_attribute):
337+
primary_measure_type = existing_value.is_a()
338+
break
339+
340+
for ifc_attribute in BOUNDED_VALUE_KEYS:
341+
if ifc_attribute not in value:
342+
continue
343+
new_value = value[ifc_attribute]
344+
if new_value is None:
345+
setattr(prop, ifc_attribute, None)
346+
continue
347+
if not isinstance(new_value, ifcopenshell.entity_instance):
348+
measure_type = primary_measure_type or self.get_primary_measure_type(prop.Name)
349+
assert measure_type, f"Couldn't find primary measure type for the prop value: '{prop.Name}'."
350+
new_value = self.file.create_entity(
351+
measure_type, self.cast_value_to_primary_measure_type(new_value, measure_type)
352+
)
353+
setattr(prop, ifc_attribute, new_value)
354+
355+
if all(getattr(prop, k) is None for k in BOUNDED_VALUE_KEYS):
356+
if self._try_purge(prop):
357+
return
358+
359+
if unit := value.get("Unit"):
360+
prop.Unit = unit
361+
else:
362+
raise ValueError(
363+
f'Value "{value}" is not a valid value for bounded property {prop.Name}. '
364+
f"Expected None or a dict with any of {BOUNDED_VALUE_KEYS} keys."
365+
)
366+
367+
del self.settings["properties"][prop.Name]
368+
return prop
369+
301370
def update_existing_prop_single_value(
302371
self, prop: ifcopenshell.entity_instance
303372
) -> Union[ifcopenshell.entity_instance, None]:
@@ -370,6 +439,9 @@ def add_new_properties(self) -> list[ifcopenshell.entity_instance]:
370439
for name, value in self.settings["properties"].items():
371440
if value is None and self.settings["should_purge"]:
372441
continue
442+
if isinstance(value, dict) and any(k in value for k in BOUNDED_VALUE_KEYS):
443+
properties.append(self.create_bounded_value_prop(name, value))
444+
continue
373445
unit, value = self.unpack_unit_value(value)
374446

375447
if isinstance(value, ifcopenshell.entity_instance):
@@ -445,6 +517,34 @@ def add_new_properties(self) -> list[ifcopenshell.entity_instance]:
445517
properties.append(self.file.create_entity("IfcPropertySingleValue", **args))
446518
return properties
447519

520+
def create_bounded_value_prop(self, name: str, value: dict[str, Any]) -> ifcopenshell.entity_instance:
521+
"""Creates a new IfcPropertyBoundedValue from a dict of LowerBoundValue /
522+
UpperBoundValue / SetPointValue (and optionally Unit).
523+
"""
524+
primary_measure_type = None
525+
for ifc_attribute in BOUNDED_VALUE_KEYS:
526+
existing_value = value.get(ifc_attribute)
527+
if isinstance(existing_value, ifcopenshell.entity_instance):
528+
primary_measure_type = existing_value.is_a()
529+
break
530+
531+
kwargs: dict[str, Any] = {"Name": name}
532+
for ifc_attribute in BOUNDED_VALUE_KEYS:
533+
if (bound_value := value.get(ifc_attribute)) is None:
534+
continue
535+
if not isinstance(bound_value, ifcopenshell.entity_instance):
536+
measure_type = primary_measure_type or self.get_primary_measure_type(name)
537+
assert measure_type, f"Couldn't find primary measure type for the prop value: '{name}'."
538+
bound_value = self.file.create_entity(
539+
measure_type, self.cast_value_to_primary_measure_type(bound_value, measure_type)
540+
)
541+
kwargs[ifc_attribute] = bound_value
542+
543+
if unit := value.get("Unit"):
544+
kwargs["Unit"] = unit
545+
546+
return self.file.create_entity("IfcPropertyBoundedValue", **kwargs)
547+
448548
def assign_new_properties(self, props: list[ifcopenshell.entity_instance]) -> None:
449549
if hasattr(self.settings["pset"], "HasProperties"):
450550
self.settings["pset"].HasProperties = props

0 commit comments

Comments
 (0)