Compare commits

...

9 Commits

Author SHA1 Message Date
Richard Brice d506f06f95 Add target-unit pickers to the Qto panels; keep unit symbols live 2026-08-10 14:37:36 -07:00
Richard Brice ebb498ddd0 Add dimensional-analysis fallback to get_project_unit() for IfcDerivedUnit 2026-08-10 14:36:28 -07:00
Richard Brice 7727a4fb4d Make quantity take-off respect manual Unit overrides; add target-unit support 2026-08-10 14:36:26 -07:00
Richard Brice c0f967e81e Add UI to pick/override a property or quantity's unit of measure in the Pset/Qto editor
Bonsai's Pset/Qto editor could display a property or quantity's own Unit
override, but had no UI to author one -- only the project-level Project
Units panel existed, which sets defaults, not per-instance overrides.
Builds on the edit_pset/edit_qto Unit-wrapping support and the
get_unit_scale/get_candidate_units helpers added in the previous commit.

- bim/prop.py: Attribute gains unit_id (the STEP id of the property's own
  override, 0 = project default) and unit_id_enum (the dropdown-driving
  dynamic enum, "Default (<symbol>)" plus every candidate unit for the
  attribute's measure type). update_attribute_unit_id converts the stored
  value live when a different unit is picked, so the physical quantity is
  preserved rather than the number being silently relabeled.
- tool/pset.py: is_measurable_special_type/get_candidate_units_for_special_type/
  resolve_effective_unit/convert_attribute_unit support the picker and the
  live conversion. get_special_type_for_prop classifies a property by its
  value's own declared measure type, falling back to an explicitly-attached
  Unit for generic numeric types (e.g. IfcReal) whose spec carries no unit
  semantics of its own but which may still legitimately carry one. Seeding
  in import_pset_from_existing ignores a stray Unit attached to a property
  whose value has no numeric/measure semantics at all (e.g. text), which
  used to crash trying to select an identifier the picker's enum items
  never include.
- bim/module/pset/ui.py: the picker widget itself, next to the value field
  in edit mode, gated on the attribute being measurable.
- bim/module/pset/operator.py: EditPset wraps measurable values with their
  chosen Unit on save, for both properties and quantities. The qto
  rounding-loop fix reaches into the wrapped dict instead of assuming a
  bare float/int, which would otherwise zero out every unit-overridden
  quantity.

Adds regression tests across all of the above, including conversion
correctness, explicit-clear/default round-trips, an unrelated sibling
property's override surviving untouched, and the stray-Unit crash guard.
2026-08-10 11:02:05 -07:00
Richard Brice 3a3c00e6f6 Add per-property/quantity Unit-override support to edit_pset/edit_qto, plus unit-scale and candidate-unit helpers
edit_pset()'s unpack_unit_value() couldn't distinguish "no Unit dict was
passed" from "{"Unit": None, ...} passed to explicitly clear an existing
override" -- both collapsed to a bare None, and every consuming call site
checked truthiness, so there was no way to actually clear a previously-set
property Unit override once one existed. Fixed with a private _NO_UNIT
sentinel; bare (unwrapped) values still leave Unit untouched exactly as
before.

edit_qto() had no Unit-handling capability at all: neither
update_existing_property() nor add_new_properties() ever read or wrote a
quantity's Unit attribute. Added the same {"Unit": ..., "NominalValue": ...}
wrapped-dict convention edit_pset() already supports, disambiguated from
the pre-existing IfcPhysicalComplexQuantity dict convention
({"Discrimination": ..., "HasQuantities": ...}) by checking for a "Unit"
key -- a complex-quantity spec never contains one.

ifcopenshell.util.unit gains two small helpers:
- get_unit_scale(unit): dispatches to get_derived_unit_scale/
  get_named_unit_scale depending on unit type, also used to de-duplicate
  calculate_unit_scale()'s own inline dispatch of the same logic.
- get_candidate_units(ifc_file, unit_type): all units in a file matching a
  given unit type, unlike get_project_unit()'s single-default lookup.

Adds regression tests for all of the above, including explicit-clear,
bare-value-preserves-override, and complex-quantity-routing-unaffected
cases.
2026-08-10 11:02:01 -07:00
Richard Brice b8211143b1 Show resolved unit symbols in read-only Pset/Qto view; add write-back and fallback regression tests
Previously, unit symbols only appeared while a Pset/Qto was in edit mode
(pencil icon) -- the read-only summary view read raw {name: value} dicts
straight from ifcopenshell.util.element.get_psets(), a completely separate
path from the Attribute/unit_symbol machinery, so it never showed a label
even after the earlier fixes. This matters for the "someone in the field
just looking at values" use case, not just editing.

- bim/module/pset/data.py: switch to get_psets(verbose=True) to get each
  property's own entity id, then resolve its unit symbol the same
  override-aware way the edit-mode path does (tool.Pset.get_unit_symbol_for_prop).
  Falls back gracefully (empty symbol) for IfcPreDefinedPropertySet
  attributes, which aren't IfcProperty entities and can't carry a Unit
  override.
- bim/module/pset/ui.py: read-only value button now shows "250 mm" instead
  of just "250".

Also adds the regression tests planned but not yet committed:
- test/tool/test_pset.py: edit a property with its own Unit override and
  write it back, confirming no rescale and the override survives.
- test/bim/test_prop.py (new): get_display_name() falls back to the plain
  name (no crash) when no unit is resolvable or the project has no units
  assigned at all.
2026-08-10 11:01:56 -07:00
Richard Brice b401393fa8 Fix get_property_unit() crash on IfcPropertySingleValue.NominalValue = None
NominalValue is optional -- IfcPropertySingleValue permits a null value --
but get_property_unit() unconditionally accessed prop.NominalValue.is_a(),
crashing on any single-value property that's legitimately blank.

Also adds a regression test confirming IfcContextDependentUnit symbols
("each", "boxes", etc.) aren't shadowed by the IfcDerivedUnit branch added
in the previous commit.
2026-08-10 11:01:35 -07:00
Richard Brice 439ea7bf9c Add IfcDerivedUnit support to ifcopenshell.util.unit (scale, symbol, dimension identification) 2026-08-10 11:00:12 -07:00
Richard Brice ae6eb4d18a Fix calculate_unit_scale() crash on SQLite-linked files
IfcSIUnit.Dimensions is a schema-derived attribute that isn't computed for
Bonsai's SQLite-linked "large model" file representation, returning None
there instead of an IfcDimensionalExponents entity. #9278 added an
unconditional unit.Dimensions.LengthExponent access to every IfcSIUnit
processed by calculate_unit_scale(), so it crashed project loading for
any linked file, even ones with no unit prefixes at all -- not just the
prefixed-area/volume case the fix targeted.

Fixed by reading dimensions from the existing si_dimensions table (keyed
by the unit's stored Name, not the unresolvable derived attribute) instead
of unit.Dimensions.

See the PR discussion for a standalone reproduction script.
2026-08-10 10:59:25 -07:00
22 changed files with 1828 additions and 166 deletions
+2 -5
View File
@@ -236,11 +236,8 @@ def import_attribute(
elif data_type == "integer":
new.int_value = 0 if new.is_null else int(data[attribute.name()])
elif data_type == "float":
attribute_type = attribute.type_of_attribute()
if attribute_type._is("IfcLengthMeasure"):
new.special_type = "LENGTH"
elif attribute_type._is("IfcForceMeasure"):
new.special_type = "FORCE"
measure_class = attribute.type_of_attribute().declared_type().name()
new.special_type = tool.Pset.get_special_type_for_measure_class(measure_class)
new.float_value = 0.0 if new.is_null else float(data[attribute.name()])
elif data_type == "enum":
attribute_type = attribute.type_of_attribute()
+17 -2
View File
@@ -54,7 +54,7 @@ class Data:
ifc_file = tool.Ifc.get()
results = []
psetqtos = ifcopenshell.util.element.get_psets(
element, psets_only=psets_only, qtos_only=qtos_only, should_inherit=False
element, psets_only=psets_only, qtos_only=qtos_only, should_inherit=False, verbose=True
)
for name, data in sorted(psetqtos.items()):
pset = ifc_file.by_id(data["id"])
@@ -69,13 +69,28 @@ class Data:
"id": data["id"],
"Name": name,
"is_expanded": is_expanded.get(data["id"], True),
"Properties": [{"Name": k, "NominalValue": v} for k, v in sorted(data.items()) if k != "id"],
"Properties": [
cls.property_display_data(ifc_file, k, v) for k, v in sorted(data.items()) if k != "id"
],
"shared_pset_uses": len(pset_uses),
"has_template": has_template,
}
)
return sorted(results, key=lambda v: v["Name"])
@classmethod
def property_display_data(cls, ifc_file: ifcopenshell.file, name: str, verbose_value: Any) -> dict[str, Any]:
# Predefined property sets (e.g. IfcDoorPanelProperties) expose plain
# attribute values even in verbose mode, since they're typed IFC
# attributes rather than IfcProperty entities with their own id/Unit.
if not isinstance(verbose_value, dict):
return {"Name": name, "NominalValue": verbose_value, "UnitSymbol": ""}
unit_symbol = ""
if (prop_id := verbose_value.get("id")) and (prop_entity := ifc_file.by_id(prop_id)):
unit_symbol = tool.Pset.get_unit_symbol_for_prop(prop_entity, ifc_file)
return {"Name": name, "NominalValue": verbose_value["value"], "UnitSymbol": unit_symbol}
@classmethod
def format_pset_enum(cls, psets):
enum_items = []
+24 -9
View File
@@ -120,13 +120,20 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
properties = json.loads(self.properties)
else:
for prop in props.properties:
metadata = prop.metadata
if prop.value_type == "IfcPropertySingleValue":
properties[prop.metadata.name] = prop.metadata.get_value()
value = metadata.get_value()
elif prop.value_type == "IfcPropertyEnumeratedValue":
value_name = prop.metadata.get_value_name()
properties[prop.metadata.name] = [
e[value_name] for e in prop.enumerated_value.enumerated_values if e.is_selected
]
value_name = metadata.get_value_name()
value = [e[value_name] for e in prop.enumerated_value.enumerated_values if e.is_selected]
else:
continue
# None (a purge/skip-creation signal, handled by edit_pset/edit_qto before any
# unit wrapping is unpacked) must stay bare -- only wrap real values.
if value is not None and tool.Pset.is_measurable_special_type(metadata.special_type):
unit = self.file.by_id(metadata.unit_id) if metadata.unit_id else None
value = {"NominalValue": value, "Unit": unit}
properties[metadata.name] = value
if pset.is_a() in ("IfcPropertySet", "IfcMaterialProperties", "IfcProfileProperties"):
ifcopenshell.api.pset.edit_pset(
@@ -140,10 +147,18 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
for key, value in properties.items():
if value is None:
continue
if isinstance(value, float):
properties[key] = round(value, 4)
elif not isinstance(value, int):
properties[key] = 0
is_wrapped = isinstance(value, dict) and "Unit" in value
raw = value["NominalValue"] if is_wrapped else value
if raw is None:
continue
if isinstance(raw, float):
raw = round(raw, 4)
elif not isinstance(raw, int):
raw = 0
if is_wrapped:
value["NominalValue"] = raw
else:
properties[key] = raw
ifcopenshell.api.pset.edit_qto(
self.file,
qto=pset,
+8 -3
View File
@@ -67,6 +67,10 @@ def draw_single_property(prop: IfcProperty, layout: bpy.types.UILayout, copy_ope
if prop.metadata.special_type == "URI":
op = layout.operator("bim.select_uri_attribute", text="", icon="FILE_FOLDER")
op.attribute_data_path = tool.Blender.get_full_data_path(prop.metadata)
if tool.Pset.is_measurable_special_type(prop.metadata.special_type):
unit_row = layout.row(align=True)
unit_row.scale_x = 0.5
prop_with_search(unit_row, prop.metadata, "unit_id_enum", text="")
if prop.metadata.is_optional:
layout.prop(prop.metadata, "is_null", icon="RADIOBUT_OFF" if prop.metadata.is_null else "RADIOBUT_ON", text="")
if copy_operator:
@@ -203,9 +207,10 @@ def draw_psetqto_ui(
row = box.row(align=True)
row.scale_y = 0.8
row.label(text=prop["Name"])
op = row.operator(
"bim.select_similar", text=get_display_value(nominal_value), icon="NONE", emboss=False
)
display_value = get_display_value(nominal_value)
if unit_symbol := prop["UnitSymbol"]:
display_value = f"{display_value} {unit_symbol}"
op = row.operator("bim.select_similar", text=display_value, icon="NONE", emboss=False)
op.key = '"' + pset["Name"].replace('"', '\\"') + '"."' + prop["Name"].replace('"', '\\"') + '"'
# calculate sum of all selected objects
if active_operator:
+2 -2
View File
@@ -151,7 +151,7 @@ class CalculateSingleQuantity(bpy.types.Operator, tool.Ifc.Operator):
ifc_file = tool.Ifc.get()
with Profiler("Quantify function time:"):
results = ifc5d.qto.quantify(ifc_file, elements, rules)
ifc5d.qto.edit_qtos(ifc_file, results)
ifc5d.qto.edit_qtos(ifc_file, results, target_units=tool.Qto.get_target_units(), rules=rules)
not_quantified_elements = elements - set(results.keys())
not_quantified_message = tool.Qto.get_not_quantified_elements_message(not_quantified_elements)
@@ -194,7 +194,7 @@ class PerformQuantityTakeOff(bpy.types.Operator, tool.Ifc.Operator):
ifc_file = tool.Ifc.get()
with Profiler("Quantify function time:"):
results = ifc5d.qto.quantify(ifc_file, elements, rules)
ifc5d.qto.edit_qtos(ifc_file, results)
ifc5d.qto.edit_qtos(ifc_file, results, target_units=tool.Qto.get_target_units(), rules=rules)
not_quantified_elements = elements - set(results.keys())
return not_quantified_elements
+28
View File
@@ -27,10 +27,28 @@ from bpy.props import (
)
from bpy.types import PropertyGroup
import bonsai.bim.prop
import bonsai.tool as tool
CALCULATOR_FUNCTION_ENUM_ITEMS: list[Union[tuple[str, str, str], None]] = []
# Measure class (matching ifc5d.qto's Function.measure / SI2ProjectUnitConverter.project_units'
# keys) -> (BIMQtoProperties field name, tool.Pset special_type, UI label).
MEASURE_TO_TARGET_UNIT_FIELD: dict[str, tuple[str, str, str]] = {
"IfcLengthMeasure": ("target_unit_length", "LENGTH", "Length"),
"IfcAreaMeasure": ("target_unit_area", "AREA", "Area"),
"IfcVolumeMeasure": ("target_unit_volume", "VOLUME", "Volume"),
"IfcMassMeasure": ("target_unit_mass", "MASS", "Mass"),
"IfcTimeMeasure": ("target_unit_time", "TIME", "Time"),
}
def _target_unit_items(special_type: str):
def getter(self: "BIMQtoProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
return bonsai.bim.prop.get_unit_enum_items_for_special_type(special_type, tool.Ifc.get())
return getter
def get_qto_rule(self: "BIMQtoProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
results: list[tuple[str, str, str]] = []
@@ -92,6 +110,11 @@ class BIMQtoProperties(PropertyGroup):
),
default=False,
)
target_unit_length: EnumProperty(items=_target_unit_items("LENGTH"), name="Length Unit")
target_unit_area: EnumProperty(items=_target_unit_items("AREA"), name="Area Unit")
target_unit_volume: EnumProperty(items=_target_unit_items("VOLUME"), name="Volume Unit")
target_unit_mass: EnumProperty(items=_target_unit_items("MASS"), name="Mass Unit")
target_unit_time: EnumProperty(items=_target_unit_items("TIME"), name="Time Unit")
if TYPE_CHECKING:
qto_rule: str
@@ -101,3 +124,8 @@ class BIMQtoProperties(PropertyGroup):
qto_name: str
prop_name: str
fallback: bool
target_unit_length: str
target_unit_area: str
target_unit_volume: str
target_unit_mass: str
target_unit_time: str
+20
View File
@@ -17,9 +17,12 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import ifc5d.qto
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
from bonsai.bim.module.qto.data import QtoData
from bonsai.bim.module.qto.prop import MEASURE_TO_TARGET_UNIT_FIELD
class BIM_PT_qto(bpy.types.Panel):
@@ -44,6 +47,14 @@ class BIM_PT_qto(bpy.types.Panel):
row = layout.row()
row.prop(props, "qto_rule", text="")
row.prop(props, "fallback", text="", icon="RADIOBUT_ON" if props.fallback else "RADIOBUT_OFF")
box = layout.box()
box.label(text="Target Units (optional, otherwise project default)")
for field_name, _special_type, label in MEASURE_TO_TARGET_UNIT_FIELD.values():
row = box.row(align=True)
row.label(text=label)
prop_with_search(row, props, field_name, text="")
row = layout.row()
row.operator("bim.perform_quantity_take_off")
@@ -66,6 +77,15 @@ class BIM_PT_qto_manual(bpy.types.Panel):
row = layout.row()
row.prop(props, "calculator_function", text="Function")
calculator = ifc5d.qto.calculators.get(props.calculator)
function = calculator.functions.get(props.calculator_function) if calculator else None
target_unit_field = MEASURE_TO_TARGET_UNIT_FIELD.get(function.measure) if function else None
if target_unit_field:
field_name, _special_type, label = target_unit_field
row = layout.row(align=True)
row.label(text=f"{label} Unit")
prop_with_search(row, props, field_name, text="")
row = layout.row(align=True)
row.prop(props, "qto_name", text="")
row.prop(props, "prop_name", text="")
+79 -37
View File
@@ -21,7 +21,6 @@ import os
from typing import TYPE_CHECKING, Any, Literal, Union, assert_never, get_args
import bpy
import ifcopenshell.util.unit
from bpy.props import (
BoolProperty,
CollectionProperty,
@@ -34,6 +33,8 @@ from bpy.props import (
)
from bpy.types import PropertyGroup
import ifcopenshell.util.unit
import bonsai.bim
import bonsai.bim.handler
import bonsai.tool as tool
@@ -122,6 +123,57 @@ def get_attribute_enum_values(prop: "Attribute", context: bpy.types.Context) ->
return items
def get_unit_enum_items_for_special_type(
special_type: str, ifc_file: Union[ifcopenshell.file, None]
) -> tool.Blender.BLENDER_ENUM_ITEMS:
"""Items for a unit-override picker: "Default (<symbol>)" plus every candidate unit
matching `special_type`, filtered per-caller since candidates depend on the measure type
in question (unlike the globally-shared lists in `bonsai.bim.ui.EnumData`).
"""
if not ifc_file or not tool.Pset.is_measurable_special_type(special_type):
return [(cache_string("0"), cache_string("Default"), "")]
default_symbol = tool.Pset.get_unit_symbol_for_special_type(special_type, ifc_file)
items: list[tuple[str, str, str]] = [
(cache_string("0"), cache_string(f"Default ({default_symbol})" if default_symbol else "Default"), "")
]
for unit in tool.Pset.get_candidate_units_for_special_type(special_type, ifc_file):
name = getattr(unit, "Name", None) or unit.is_a()
symbol = ifcopenshell.util.unit.get_unit_symbol(unit)
label = f"{name} ({symbol})" if symbol else name
items.append((cache_string(str(unit.id())), cache_string(label), ""))
return items
def get_attribute_unit_enum_items(prop: "Attribute", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
"""Items for `Attribute.unit_id_enum`. Wraps `get_unit_enum_items_for_special_type` with a
defensive addition: real-world files sometimes carry a Unit that doesn't cleanly match our
candidate-matching logic (e.g. a mismatched UnitType). Always keep the attribute's own
current override selectable/representable, however unusual, so setting unit_id_enum to
match an already-seeded unit_id can never raise "enum not found".
"""
ifc_file = tool.Ifc.get()
items = get_unit_enum_items_for_special_type(prop.special_type, ifc_file)
if prop.unit_id and prop.unit_id not in {int(i[0]) for i in items}:
own_unit = ifc_file.by_id(prop.unit_id)
name = getattr(own_unit, "Name", None) or own_unit.is_a()
symbol = ifcopenshell.util.unit.get_unit_symbol(own_unit)
label = f"{name} ({symbol})" if symbol else name
items.append((cache_string(str(prop.unit_id)), cache_string(label), ""))
return items
def update_attribute_unit_id(self: "Attribute", context: bpy.types.Context) -> None:
new_unit_id = int(tool.Blender.get_enum_safe(self, "unit_id_enum") or "0")
if ifc_file := tool.Ifc.get():
# Must run before self.unit_id is overwritten: convert_attribute_unit needs the OLD
# unit_id to know what unit the current value is expressed in.
tool.Pset.convert_attribute_unit(self, new_unit_id, ifc_file)
self.unit_id = new_unit_id
def update_schema_dir(self: "BIMProperties", context: bpy.types.Context) -> None:
import bonsai.bim.schema
@@ -250,44 +302,33 @@ def set_numerical_value(self: "Attribute", value_name: str, new_value: Union[flo
self[value_name] = new_value
def get_length_value(self: "Attribute") -> float:
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
return self.float_value * si_conversion
def set_length_value(self: "Attribute", value: float) -> None:
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
self.float_value = value / si_conversion
def get_display_name(self: "Attribute") -> str:
DISPLAY_UNIT_TYPES = ("AREA", "VOLUME", "FORCE")
name = self.name
if not self.special_type or self.special_type not in DISPLAY_UNIT_TYPES:
if not self.unit_symbol:
return name
return f"{name}, {self.unit_symbol}"
unit_type = f"{self.special_type}UNIT"
project_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), unit_type)
if not project_unit:
return name
unit_symbol = ifcopenshell.util.unit.get_unit_symbol(project_unit)
return f"{name}, {unit_symbol}"
def get_unit_symbol(self: "Attribute") -> str:
"""The symbol for whatever unit the value is currently expressed in: this property's own
override (`unit_id`) if set, else the project default for `special_type`. Computed fresh on
every access (rather than cached at import time) so it stays correct immediately after the
unit picker changes `unit_id`, and after the project's own default units are edited.
"""
if not tool.Pset.is_measurable_special_type(self.special_type):
return ""
if not (ifc_file := tool.Ifc.get()):
return ""
unit = tool.Pset.resolve_effective_unit(self.special_type, self.unit_id, ifc_file)
return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else ""
AttributeDataType = Literal["string", "integer", "float", "boolean", "enum", "file", "list[string]"]
AttributeSpecialType = Literal[
"",
"DATE",
"DATETIME",
"LENGTH",
"AREA",
"VOLUME",
"FORCE",
"LOGICAL",
"URI",
"DURATION",
]
# Either "", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION", or an
# IfcUnitEnum/IfcDerivedUnitEnum value with the "UNIT" suffix stripped (e.g.
# "LENGTH", "PRESSURE", "MODULUSOFELASTICITY") as returned by
# tool.Pset.get_special_type_for_prop().
AttributeSpecialType = str
class Attribute(PropertyGroup):
@@ -318,9 +359,6 @@ class Attribute(PropertyGroup):
get=lambda self: float(self.get("float_value", 0.0)),
set=set_float_value,
)
length_value: FloatProperty(
name="Value", description=tooltip, get=get_length_value, set=set_length_value, unit="LENGTH"
)
enum_items: StringProperty(name="Value")
"""Json serialized mapping of enum items:
Typically a dictionary of string identifiers to item names.
@@ -342,6 +380,10 @@ class Attribute(PropertyGroup):
value_max: FloatProperty(description="This is used to validate int_value and float_value")
value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound")
special_type: StringProperty(name="Special Value Type", default="")
unit_symbol: StringProperty(name="Unit Symbol", get=get_unit_symbol)
unit_id: IntProperty(name="Unit Override", default=0)
"""STEP id of this property/quantity's own Unit override. 0 means "use the project default"."""
unit_id_enum: EnumProperty(items=get_attribute_unit_enum_items, name="Unit", update=update_attribute_unit_id)
use_explorer_ui: BoolProperty()
metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute")
update: StringProperty(name="Update", description="Custom update function to be executed")
@@ -357,7 +399,6 @@ class Attribute(PropertyGroup):
bool_value: bool
int_value: int
float_value: float
length_value: float
enum_items: str
enum_items_dynamic: str
enum_descriptions: bpy.types.bpy_prop_collection_idprop[StrProperty]
@@ -373,6 +414,9 @@ class Attribute(PropertyGroup):
value_min_constraint: bool
value_max: float
value_max_constraint: bool
unit_symbol: str
unit_id: int
unit_id_enum: str
use_explorer_ui: bool
metadata: str
update: str
@@ -430,8 +474,6 @@ class Attribute(PropertyGroup):
elif data_type == "integer":
return "int_value"
elif data_type == "float":
if display_only and self.special_type == "LENGTH":
return "length_value"
return "float_value"
elif data_type == "enum":
return "enum_value"
+147 -33
View File
@@ -26,6 +26,7 @@ import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.util.attribute
import ifcopenshell.util.element
import ifcopenshell.util.unit
import bonsai.bim.helper
import bonsai.bim.schema
@@ -168,42 +169,144 @@ class Pset(bonsai.core.tool.Pset):
pset_id=0, pset_name=cls.get_pset_name(obj, obj_type), pset_type="PSET", obj=obj, obj_type=obj_type
)
# Templates for quantities can specify their kind via TemplateType (e.g.
# "Q_LENGTH") instead of PrimaryMeasureType. IfcQuantityCount has no
# associated measure/unit, so it is intentionally absent here.
QUANTITY_TEMPLATE_TYPE_TO_SPECIAL_TYPE = {
"Q_LENGTH": "LENGTH",
"Q_AREA": "AREA",
"Q_VOLUME": "VOLUME",
"Q_WEIGHT": "MASS",
"Q_TIME": "TIME",
}
@classmethod
def get_special_type_for_prop(
cls, prop_or_prop_template: ifcopenshell.entity_instance
) -> Literal["LENGTH"] | Literal["AREA"] | Literal["VOLUME"] | Literal["URI"] | Literal[""]:
special_type = ""
def get_special_type_for_measure_class(cls, measure_class: str) -> str:
"""Get the ``special_type`` (an IfcUnitEnum value with "UNIT" stripped) for an IFC measure class.
:param measure_class: An IFC measure class name, e.g. "IfcLengthMeasure".
:return: E.g. "LENGTH", or "" if the class has no associated unit type.
"""
if not measure_class.endswith("Measure"):
return ""
unit_type = ifcopenshell.util.unit.get_measure_unit_type(measure_class)
return unit_type[: -len("UNIT")] if unit_type.endswith("UNIT") else ""
@classmethod
def get_special_type_for_unit(cls, unit: ifcopenshell.entity_instance) -> str:
"""Get the ``special_type`` (an IfcUnitEnum value with "UNIT" stripped) directly from
a Unit entity, for properties whose NominalValue is a generic numeric type (e.g.
IfcReal) rather than a proper measure class, but which still carry a real Unit.
"""
unit_type = getattr(unit, "UnitType", None)
if unit_type and unit_type != "USERDEFINED":
return unit_type[: -len("UNIT")] if unit_type.endswith("UNIT") else ""
dimension_type = ifcopenshell.util.unit.identify_unit_dimensions(unit)
return dimension_type[: -len("UNIT")] if dimension_type else ""
@classmethod
def get_special_type_for_prop(cls, prop_or_prop_template: ifcopenshell.entity_instance) -> str:
"""Classify a property/quantity/template by its measure type.
:return: An IfcUnitEnum value with the "UNIT" suffix stripped (e.g.
"LENGTH", "PRESSURE"), "URI" for IfcURIReference, or "" if the
value has no associated unit type.
"""
if prop_or_prop_template.is_a("IfcPropertyTemplate"):
primary_measure_type = prop_or_prop_template.PrimaryMeasureType
template_type = prop_or_prop_template.TemplateType
if primary_measure_type in ("IfcPositiveLengthMeasure", "IfcLengthMeasure") or template_type == "Q_LENGTH":
special_type = "LENGTH"
elif primary_measure_type == "IfcAreaMeasure" or template_type == "Q_AREA":
special_type = "AREA"
elif primary_measure_type == "IfcVolumeMeasure" or template_type == "Q_VOLUME":
special_type = "VOLUME"
elif primary_measure_type == "IfcURIReference":
special_type = "URI"
else:
if prop_or_prop_template.is_a("IfcPropertySingleValue"):
value = prop_or_prop_template.NominalValue
if value is not None:
value_type = value.is_a()
if value_type in ("IfcLengthMeasure", "IfcPositiveLengthMeasure"):
special_type = "LENGTH"
elif value_type == "IfcAreaMeasure":
special_type = "AREA"
elif value_type == "IfcVolumeMeasure":
special_type = "VOLUME"
elif prop_or_prop_template.is_a("IfcPhysicalSimpleQuantity"):
prop_class = prop_or_prop_template.is_a()
if prop_class == "IfcQuantityArea":
special_type = "AREA"
elif prop_class == "IfcQuantityVolume":
special_type = "VOLUME"
elif prop_class == "IfcQuantityLength":
special_type = "LENGTH"
return special_type
if primary_measure_type == "IfcURIReference":
return "URI"
if primary_measure_type:
return cls.get_special_type_for_measure_class(primary_measure_type)
return cls.QUANTITY_TEMPLATE_TYPE_TO_SPECIAL_TYPE.get(prop_or_prop_template.TemplateType, "")
elif prop_or_prop_template.is_a("IfcPropertySingleValue"):
value = prop_or_prop_template.NominalValue
if value is not None:
special_type = cls.get_special_type_for_measure_class(value.is_a())
if special_type:
return special_type
# Some property sets declare a generic numeric type (e.g. IfcReal) rather
# than a proper measure class, relying on an explicit Unit attribute alone to
# convey the dimension. Still measurable -- derive special_type from the Unit
# itself rather than (fruitlessly) from NominalValue's declared type.
if value.is_a() in ("IfcReal", "IfcInteger"):
if unit := getattr(prop_or_prop_template, "Unit", None):
return cls.get_special_type_for_unit(unit)
elif prop_or_prop_template.is_a("IfcPhysicalSimpleQuantity"):
entity = prop_or_prop_template.wrapped_data.declaration().as_entity()
measure_class = entity.attribute_by_index(3).type_of_attribute().declared_type().name()
return cls.get_special_type_for_measure_class(measure_class)
return ""
@classmethod
def get_unit_symbol_for_special_type(cls, special_type: str, ifc_file: ifcopenshell.file) -> str:
"""Get the project's default unit symbol for a `special_type` (see `get_special_type_for_prop`).
Used where there's no property instance to check for a `Unit` override
(e.g. a template, or a native IFC entity attribute, neither of which
can carry one).
"""
if not special_type or special_type == "URI":
return ""
unit = ifcopenshell.util.unit.get_project_unit(ifc_file, f"{special_type}UNIT")
return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else ""
@classmethod
def get_unit_symbol_for_prop(cls, prop: ifcopenshell.entity_instance, ifc_file: ifcopenshell.file) -> str:
"""Get the unit symbol for an existing property/quantity, respecting its own `Unit` override.
Gated on the property being classified as measurable (see `get_special_type_for_prop`,
which already accounts for a Unit attached to a generic numeric value) -- this only
excludes a Unit attached to a property whose value has no numeric/measure semantics at
all (e.g. text), where a stray Unit shouldn't be surfaced as a resolved unit.
"""
if not cls.is_measurable_special_type(cls.get_special_type_for_prop(prop)):
return ""
unit = ifcopenshell.util.unit.get_property_unit(prop, ifc_file)
return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else ""
# special_type values that don't denote a real unit-bearing measure (see get_special_type_for_prop).
NON_MEASURABLE_SPECIAL_TYPES = frozenset({"", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION"})
@classmethod
def is_measurable_special_type(cls, special_type: str) -> bool:
"""True if `special_type` (see `get_special_type_for_prop`) denotes a real unit-bearing measure."""
return special_type not in cls.NON_MEASURABLE_SPECIAL_TYPES
@classmethod
def get_candidate_units_for_special_type(
cls, special_type: str, ifc_file: ifcopenshell.file
) -> list[ifcopenshell.entity_instance]:
"""All units in the file usable as an override for a `special_type` (see `get_special_type_for_prop`)."""
if not cls.is_measurable_special_type(special_type):
return []
return ifcopenshell.util.unit.get_candidate_units(ifc_file, f"{special_type}UNIT")
@classmethod
def resolve_effective_unit(
cls, special_type: str, unit_id: int, ifc_file: ifcopenshell.file
) -> Union[ifcopenshell.entity_instance, None]:
"""The unit a value is currently expressed in: its own override (`unit_id`, a STEP id,
0 meaning "no override"), or the project default for `special_type` otherwise."""
if unit_id:
return ifc_file.by_id(unit_id)
return ifcopenshell.util.unit.get_project_unit(ifc_file, f"{special_type}UNIT")
@classmethod
def convert_attribute_unit(cls, metadata: "Attribute", new_unit_id: int, ifc_file: ifcopenshell.file) -> None:
"""Rescale `metadata.float_value` in place so its physical quantity is preserved when
switching from its current effective unit to the unit named by `new_unit_id` (0 = project
default). No-op for non-measurable attributes or when old and new resolve to the same unit.
"""
if not cls.is_measurable_special_type(metadata.special_type):
return
old_unit = cls.resolve_effective_unit(metadata.special_type, metadata.unit_id, ifc_file)
new_unit = cls.resolve_effective_unit(metadata.special_type, new_unit_id, ifc_file)
if old_unit is None or new_unit is None or old_unit == new_unit:
return
old_scale = ifcopenshell.util.unit.get_unit_scale(old_unit)
new_scale = ifcopenshell.util.unit.get_unit_scale(new_unit)
metadata.float_value = metadata.float_value * old_scale / new_scale
@classmethod
def import_pset_from_existing(
@@ -283,6 +386,15 @@ class Pset(bonsai.core.tool.Pset):
metadata.is_null = value is None
metadata.is_optional = True
metadata.special_type = cls.get_special_type_for_prop(prop)
# The prop's OWN Unit override only -- metadata.unit_symbol is computed fresh from
# special_type/unit_id on every access (see Attribute.get_unit_symbol), so it
# already accounts for the project-default fallback once unit_id is set below.
# Some real-world files (e.g. certain exporters) set Unit on properties that
# aren't actually measures -- ignore it there, since we only ever treat Unit as
# meaningful for measurable special_types (matching the UI picker's own gating).
own_unit = getattr(prop, "Unit", None) if cls.is_measurable_special_type(metadata.special_type) else None
metadata.unit_id = own_unit.id() if own_unit else 0
metadata.unit_id_enum = str(metadata.unit_id)
metadata.set_value(metadata.get_value_default() if metadata.is_null else value)
process_prop_description(metadata)
@@ -409,6 +521,8 @@ class Pset(bonsai.core.tool.Pset):
cls.import_single_value_from_template(pset_template, prop_template, simplified_data, props)
elif prop_template.TemplateType.startswith("Q_"):
if prop_data:
continue # Existing quantity will be added later by import_pset_from_existing.
cls.import_single_value_from_template(pset_template, prop_template, simplified_data, props)
elif prop_template.TemplateType == "P_ENUMERATEDVALUE":
+13
View File
@@ -176,6 +176,19 @@ class Qto(bonsai.core.tool.Qto):
is_ifc4x3 = ifc_file.schema == "IFC4X3"
return {rule_id: rule for rule_id, rule in ifc5d.qto.rules.items() if rule_id.startswith("IFC4X3") == is_ifc4x3}
@classmethod
def get_target_units(cls) -> dict[str, ifcopenshell.entity_instance]:
from bonsai.bim.module.qto.prop import MEASURE_TO_TARGET_UNIT_FIELD
props = cls.get_qto_props()
ifc_file = tool.Ifc.get()
target_units: dict[str, ifcopenshell.entity_instance] = {}
for measure_class, (field_name, _special_type, _label) in MEASURE_TO_TARGET_UNIT_FIELD.items():
unit_id = int(tool.Blender.get_enum_safe(props, field_name) or "0")
if unit_id:
target_units[measure_class] = ifc_file.by_id(unit_id)
return target_units
@classmethod
def get_not_quantified_elements_message(cls, not_quantified_elements: set[ifcopenshell.entity_instance]) -> str:
not_quantified_message = ""
+1 -15
View File
@@ -361,31 +361,22 @@ Scenario: Edit pset length property
Given an empty IFC project
And I press "mesh.add_stair"
And the variable "pset" is "tool.Pset.get_element_pset(tool.Ifc.get_entity(bpy.context.active_object), 'Pset_StairFlightCommon').id()"
And the variable "si_conversion" is "ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())"
And I press "bim.enable_pset_editing(pset_id={pset}, obj='IfcStairFlight/StairFlight', obj_type='Object')"
# Testing IfcPositiveLengthMeasure type of prop
Then "active_object.PsetProperties.properties['TreadLength'].metadata.special_type" is "LENGTH"
And "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is "250"
And "active_object.PsetProperties.properties['TreadLength'].metadata.length_value" is roughly "0.25"
When I set "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" to "350"
Then "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is roughly "350"
When I set "active_object.PsetProperties.properties['TreadLength'].metadata.length_value" to "0.45"
Then "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is roughly "450"
# Testing IfcLengthMeasure type of prop
Then "active_object.PsetProperties.properties['NosingLength'].metadata.special_type" is "LENGTH"
And "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is "0.0"
And "active_object.PsetProperties.properties['NosingLength'].metadata.length_value" is roughly "0.0"
When I set "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" to "350"
Then "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is roughly "350"
When I set "active_object.PsetProperties.properties['NosingLength'].metadata.length_value" to "0.45"
Then "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is roughly "450"
When I press "bim.edit_pset(obj='IfcStairFlight/StairFlight', obj_type='Object')"
Then nothing happens
@@ -394,19 +385,14 @@ Scenario: Edit qset length property
And I press "mesh.add_stair"
And I press "bim.perform_quantity_take_off"
And the variable "pset" is "tool.Pset.get_element_pset(tool.Ifc.get_entity(bpy.context.active_object), 'Qto_StairFlightBaseQuantities').id()"
And the variable "si_conversion" is "ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())"
And I press "bim.enable_pset_editing(pset_id={pset}, obj='IfcStairFlight/StairFlight', obj_type='Object')"
# Testing Q_LENGTH type of prop
Then "active_object.PsetProperties.properties['Length'].metadata.special_type" is "LENGTH"
And "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "2156.485"
And "active_object.PsetProperties.properties['Length'].metadata.length_value" is roughly "2.156"
When I set "active_object.PsetProperties.properties['Length'].metadata.float_value" to "350"
Then "active_object.PsetProperties.properties['Length'].metadata.length_value" is roughly "0.35"
When I set "active_object.PsetProperties.properties['Length'].metadata.length_value" to "0.45"
Then "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "450"
Then "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "350"
When I press "bim.edit_pset(obj='IfcStairFlight/StairFlight', obj_type='Object')"
Then nothing happens
+328
View File
@@ -0,0 +1,328 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import bpy
import ifcopenshell
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.unit
import pytest
import bonsai.bim.prop
import bonsai.tool as tool
from test.bim.bootstrap import NewFile
def import_single_property(ifc, element, prop):
"""Import a single existing IfcProperty into a real, addon-registered
PsetProperties collection, exactly as the property editor does, and
return its `metadata` (an `Attribute`)."""
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
pset.HasProperties = [prop]
obj = bpy.data.objects.new(prop.Name, None)
tool.Ifc.link(element, obj)
props = obj.PsetProperties
tool.Pset.import_pset_from_existing(pset, props, None)
return props.properties[prop.Name].metadata
class TestGetDisplayName(NewFile):
def test_appends_the_resolved_unit_symbol(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
pressure = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="PRESSUREUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[pressure])
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0))
metadata = import_single_property(ifc, element, prop)
assert metadata.display_name == "Foo, Pa"
def test_falls_back_to_the_plain_name_when_no_unit_is_resolvable(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
# No units assigned to the project at all -- nothing to resolve.
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0))
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_symbol == ""
assert metadata.display_name == "Foo"
def test_falls_back_to_the_plain_name_for_a_non_measure_property(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcText("Bar"))
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_symbol == ""
assert metadata.display_name == "Foo"
def test_resolves_a_unit_explicitly_attached_to_a_generic_numeric_value(self):
# A generic IfcReal has no unit semantics per its own declared type, but a property
# set may still explicitly attach a real Unit to a specific instance to convey the
# dimension the spec's generic typing doesn't. That explicit Unit is real, deliberate
# data (not incidental/stray), so it should resolve normally.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcReal(150.0), Unit=length_mm)
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_symbol == "mm"
assert metadata.display_name == "Foo, mm"
class TestGetAttributeUnitEnumItems(NewFile):
def test_returns_default_plus_one_per_candidate_for_a_measurable_type(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5))
metadata = import_single_property(ifc, element, prop)
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
identifiers = [i[0] for i in items]
assert identifiers[0] == "0"
assert str(length_mm.id()) in identifiers
assert str(length_m.id()) in identifiers
assert len(items) == 3 # Default + mm + m
def test_returns_just_default_for_non_measurable_types(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcText("Bar"))
metadata = import_single_property(ifc, element, prop)
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
assert [i[0] for i in items] == ["0"]
class TestGetUnitEnumItemsForSpecialType(NewFile):
def test_matches_the_attribute_wrapper_output(self):
# Regression test for extracting get_unit_enum_items_for_special_type out of
# get_attribute_unit_enum_items: the wrapper must still produce identical items for
# the plain (no own-unit-fallback-needed) case.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5))
metadata = import_single_property(ifc, element, prop)
direct_items = bonsai.bim.prop.get_unit_enum_items_for_special_type(metadata.special_type, ifc)
wrapper_items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
assert direct_items == wrapper_items
def test_returns_just_default_when_ifc_file_is_none(self):
assert bonsai.bim.prop.get_unit_enum_items_for_special_type("LENGTH", None) == [("0", "Default", "")]
class TestUnitSymbolWithAreaVolumeDerivedFromLength(NewFile):
"""Regression test: AREAUNIT/VOLUMEUNIT have no IfcDerivedUnitEnum member, so a project
whose area/volume default is an IfcDerivedUnit rather than a literal-UnitType-matching
IfcSIUnit/IfcConversionBasedUnit has no literal UnitType to match on.
ifcopenshell.util.unit.get_project_unit() used to only match by literal UnitType, so the
read-only unit symbol and the edit-mode "Default (<symbol>)" picker entry both silently
fell back to no symbol at all in that case.
"""
def setup_project_with_derived_area_and_volume(self, ifc):
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
area = ifcopenshell.api.unit.add_derived_unit(ifc, "USERDEFINED", "area-ish", {length: 2})
volume = ifcopenshell.api.unit.add_derived_unit(ifc, "USERDEFINED", "volume-ish", {length: 3})
ifcopenshell.api.unit.assign_unit(ifc, units=[length, area, volume])
def test_default_picker_entry_shows_the_resolved_symbol(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
self.setup_project_with_derived_area_and_volume(ifc)
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcAreaMeasure(5.0))
metadata = import_single_property(ifc, element, prop)
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
assert items[0][1] == "Default (m2)"
def test_read_only_display_resolves_the_symbol(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
self.setup_project_with_derived_area_and_volume(ifc)
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcVolumeMeasure(5.0))
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_symbol == "m3"
assert metadata.display_name == "Foo, m3"
class TestUpdateAttributeUnitId(NewFile):
def test_syncs_unit_id_and_converts_float_value_when_unit_id_enum_changes(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2500.0))
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_id == 0
assert metadata.float_value == 2500.0
assert metadata.unit_symbol == "mm"
assert metadata.display_name == "Foo, mm"
metadata.unit_id_enum = str(length_m.id())
assert metadata.unit_id == length_m.id()
assert metadata.float_value == pytest.approx(2.5) # converted, not just relabeled
# Regression test: unit_symbol/display_name used to be a snapshot taken once at import
# time, so picking a different unit converted the value but left the label showing the
# old unit's symbol.
assert metadata.unit_symbol == "m"
assert metadata.display_name == "Foo, m"
class TestUnitSymbolReflectsLiveProjectState(NewFile):
def test_symbol_updates_after_a_project_default_unit_is_assigned_later(self):
# Regression test: unit_symbol used to be a snapshot computed once at import time, so a
# property/quantity imported before its measure type had a project default unit assigned
# kept showing no symbol even after one was added, unless the panel was closed and
# reopened (re-triggering import). It's now computed fresh on every access.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
# No AREAUNIT assigned yet.
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcAreaMeasure(5.0))
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_symbol == ""
assert metadata.display_name == "Foo"
area = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="AREAUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[area])
assert metadata.unit_symbol == "m2"
assert metadata.display_name == "Foo, m2"
class TestImportPsetFromExistingWithAGenericNumericValueAndAnExplicitUnit(NewFile):
def test_run(self):
# Regression test: some property set specifications declare a property as a generic
# IfcReal rather than a proper measure class, relying on an explicit Unit attribute
# alone to convey the dimension. get_property_unit() already handled this fine for
# display (it checks prop.Unit before looking at NominalValue's type at all), but
# get_special_type_for_prop() only looked at NominalValue's class ending in "Measure"
# -- so special_type came back "", the picker never appeared, and the real Unit
# override never got seeded into unit_id even though it was legitimately set.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcReal(150.0), Unit=length_mm
)
metadata = import_single_property(ifc, element, prop)
assert metadata.special_type == "LENGTH"
assert tool.Pset.is_measurable_special_type(metadata.special_type)
assert metadata.unit_symbol == "mm"
assert metadata.unit_id == length_mm.id()
assert metadata.unit_id_enum == str(length_mm.id())
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
assert str(length_mm.id()) in [i[0] for i in items]
class TestImportPsetFromExistingWithAStrayUnitOnANonMeasureProperty(NewFile):
def test_run(self):
# Regression test: some real-world exporters set a Unit on a property whose
# NominalValue isn't actually a measure (e.g. a text classification), which used
# to crash import_pset_from_existing with "enum '<id>' not found in ('0')" -- unit_id
# was seeded from prop.Unit unconditionally, before the special_type gate that decides
# whether Unit is even meaningful for this property.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcLabel("Bar"), Unit=length_m
)
metadata = import_single_property(ifc, element, prop) # must not raise
assert metadata.special_type == ""
assert metadata.unit_id == 0
assert metadata.unit_id_enum == "0"
class TestGetAttributeUnitEnumItemsWithAMismatchedUnit(NewFile):
def test_own_unit_is_always_representable_even_if_not_a_normal_candidate(self):
# Regression test: a property's own Unit might not satisfy
# get_candidate_units_for_special_type's matching (e.g. mismatched UnitType in messy
# real-world data). Seeding must never crash trying to select it, and it should still
# show up in the picker so the user can see/change it.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
# A LENGTHUNIT attached to a PRESSURE-typed property -- a real mismatch, not a candidate
# get_candidate_units_for_special_type("PRESSURE", ...) would ever return.
mismatched_unit = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0), Unit=mismatched_unit
)
metadata = import_single_property(ifc, element, prop) # must not raise
assert metadata.special_type == "PRESSURE"
assert metadata.unit_id == mismatched_unit.id()
assert metadata.unit_id_enum == str(mismatched_unit.id())
items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
assert str(mismatched_unit.id()) in [i[0] for i in items]
+340
View File
@@ -20,6 +20,9 @@ import bpy
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.unit
import pytest
import bonsai.core.tool
import bonsai.tool as tool
@@ -52,3 +55,340 @@ class TestIsPsetEmpty(NewFile):
assert subject.is_pset_empty(pset) is False
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties={"Foo": None})
assert subject.is_pset_empty(pset) is True
class TestEditingAnOverriddenUnitPropertyRoundTrips(NewFile):
def test_run(self):
# Project default is mm, but this property is authored directly in m.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5), Unit=length_m
)
pset.HasProperties = [prop]
obj = bpy.data.objects.new("Wall", None)
tool.Ifc.link(element, obj)
blender_props = obj.PsetProperties
subject.import_pset_from_existing(pset, blender_props, None)
metadata = blender_props.properties["Foo"].metadata
assert metadata.unit_symbol == "m"
assert metadata.float_value == 2.5 # raw stored value, not rescaled to the project's mm
# Simulate a user edit in the property editor.
metadata.float_value = 3.5
# Simulate what EditPset.execute() does: collect the raw value straight
# off the metadata and write it back, with no rescaling step.
properties = {"Foo": metadata.get_value()}
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
assert prop.NominalValue.wrappedValue == 3.5 # not rescaled to 3500mm
assert prop.Unit == length_m # override preserved
class TestImportingATemplatedQuantityRespectsItsOwnUnitOverride(NewFile):
def test_run(self):
# Regression test: import_pset_from_template's Q_ branch used to
# unconditionally re-template existing quantities, which shadowed
# their own Unit override with the project default -- edit mode
# showed "m" while the read-only panel correctly showed "mm".
# Project default is m, but this quantity is authored directly in mm.
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
element = ifc.createIfcBeam()
qto = ifcopenshell.api.pset.add_qto(ifc, product=element, name="Qto_Test")
quantity = ifc.createIfcQuantityLength(Name="Foo", Unit=length_mm, LengthValue=2500.0)
qto.Quantities = [quantity]
pset_template = ifc.createIfcPropertySetTemplate(
Name="Qto_Test",
TemplateType="PSET_TYPEDRIVENOVERRIDE",
ApplicableEntity="IfcBeam",
HasPropertyTemplates=[ifc.createIfcSimplePropertyTemplate(Name="Foo", TemplateType="Q_LENGTH")],
)
obj = bpy.data.objects.new("Beam", None)
tool.Ifc.link(element, obj)
blender_props = obj.PsetProperties
# Mirrors core/pset.py's enable_pset_editing: template pass, then existing-data pass.
subject.import_pset_from_template(pset_template, qto, blender_props)
subject.import_pset_from_existing(qto, blender_props, pset_template)
assert len(blender_props.properties) == 1 # not duplicated by the template pass
metadata = blender_props.properties["Foo"].metadata
assert metadata.unit_symbol == "mm" # the quantity's own override, not the project default "m"
assert metadata.float_value == 2500.0 # raw stored value, not rescaled
class TestIsMeasurableSpecialType(NewFile):
def test_run(self):
for special_type in ("", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION"):
assert subject.is_measurable_special_type(special_type) is False
assert subject.is_measurable_special_type("LENGTH") is True
assert subject.is_measurable_special_type("PRESSURE") is True
class TestGetCandidateUnitsForSpecialType(NewFile):
def test_returns_candidates_matching_the_special_type(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.add_si_unit(ifc, unit_type="AREAUNIT")
assert set(subject.get_candidate_units_for_special_type("LENGTH", ifc)) == {length_mm, length_m}
def test_gating_returns_empty_for_non_measurable_special_types(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
assert subject.get_candidate_units_for_special_type("", ifc) == []
assert subject.get_candidate_units_for_special_type("URI", ifc) == []
class TestResolveEffectiveUnit(NewFile):
def test_own_override_takes_precedence_over_project_default(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
assert subject.resolve_effective_unit("LENGTH", length_m.id(), ifc) == length_m
def test_falls_back_to_project_default_when_unit_id_is_zero(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
assert subject.resolve_effective_unit("LENGTH", 0, ifc) == length_mm
class TestConvertAttributeUnit(NewFile):
def _new_metadata(self, ifc: ifcopenshell.file):
element = ifc.createIfcWall()
obj = bpy.data.objects.new("Wall", None)
tool.Ifc.link(element, obj)
props = obj.PsetProperties
new_prop = props.properties.add()
new_prop.name = "Foo"
return new_prop.metadata
def test_converts_value_between_two_explicit_units(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
metadata = self._new_metadata(ifc)
metadata.special_type = "LENGTH"
metadata.unit_id = length_mm.id()
metadata.float_value = 2500.0
subject.convert_attribute_unit(metadata, length_m.id(), ifc)
assert metadata.float_value == pytest.approx(2.5)
def test_converts_value_when_switching_to_and_from_the_project_default(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
length_ft = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="foot")
metadata = self._new_metadata(ifc)
metadata.special_type = "LENGTH"
metadata.unit_id = length_ft.id()
metadata.float_value = 10.0 # 10 ft
subject.convert_attribute_unit(metadata, 0, ifc) # 0 = switch to project default (m)
assert metadata.float_value == pytest.approx(3.048)
def test_noop_when_old_and_new_resolve_to_the_same_unit(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
metadata = self._new_metadata(ifc)
metadata.special_type = "LENGTH"
metadata.unit_id = 0 # already resolves to length_m (the project default)
metadata.float_value = 5.0
subject.convert_attribute_unit(metadata, length_m.id(), ifc)
assert metadata.float_value == 5.0
def test_noop_for_a_non_measurable_special_type(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
metadata = self._new_metadata(ifc)
metadata.special_type = ""
metadata.unit_id = 0
metadata.float_value = 5.0
subject.convert_attribute_unit(metadata, length_m.id(), ifc)
assert metadata.float_value == 5.0
def _build_wrapped_properties_from_ui(blender_props) -> dict:
"""Mirrors EditPset._execute()'s properties-building loop (operator.py)."""
properties = {}
for entry in blender_props.properties:
metadata = entry.metadata
value = metadata.get_value()
if value is not None and subject.is_measurable_special_type(metadata.special_type):
unit = tool.Ifc.get().by_id(metadata.unit_id) if metadata.unit_id else None
value = {"NominalValue": value, "Unit": unit}
properties[metadata.name] = value
return properties
class TestEditPsetWithUnitOverridePicker(NewFile):
def test_picking_a_different_unit_converts_the_displayed_value_and_writes_it_back(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2500.0))
pset.HasProperties = [prop]
obj = bpy.data.objects.new("Wall", None)
tool.Ifc.link(element, obj)
blender_props = obj.PsetProperties
subject.import_pset_from_existing(pset, blender_props, None)
metadata = blender_props.properties["Foo"].metadata
assert metadata.unit_id == 0
assert metadata.float_value == 2500.0
# Simulate the user picking "m" in the unit picker dropdown.
metadata.unit_id_enum = str(length_m.id())
assert metadata.float_value == pytest.approx(2.5) # converted live, not just relabeled
assert metadata.unit_id == length_m.id()
properties = _build_wrapped_properties_from_ui(blender_props)
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
assert prop.NominalValue.wrappedValue == pytest.approx(2.5)
assert prop.Unit == length_m
def test_picking_default_after_an_override_converts_back_and_clears_the_unit(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
element = ifc.createIfcWall()
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5), Unit=length_m
)
pset.HasProperties = [prop]
obj = bpy.data.objects.new("Wall", None)
tool.Ifc.link(element, obj)
blender_props = obj.PsetProperties
subject.import_pset_from_existing(pset, blender_props, None)
metadata = blender_props.properties["Foo"].metadata
assert metadata.unit_id == length_m.id()
assert metadata.float_value == 2.5
# Simulate picking "Default" (mm).
metadata.unit_id_enum = "0"
assert metadata.float_value == pytest.approx(2500.0)
assert metadata.unit_id == 0
properties = _build_wrapped_properties_from_ui(blender_props)
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
assert prop.Unit is None
assert prop.NominalValue.wrappedValue == pytest.approx(2500.0)
def test_editing_an_unrelated_sibling_property_does_not_disturb_this_ones_override(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(ifc, units=[length_m])
length_ft = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="foot")
element = ifc.createIfcWall()
pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test")
overridden_prop = ifc.createIfcPropertySingleValue(
Name="Foo", NominalValue=ifc.createIfcLengthMeasure(10.0), Unit=length_ft
)
untouched_prop = ifc.createIfcPropertySingleValue(Name="Bar", NominalValue=ifc.createIfcLengthMeasure(3.0))
pset.HasProperties = [overridden_prop, untouched_prop]
obj = bpy.data.objects.new("Wall", None)
tool.Ifc.link(element, obj)
blender_props = obj.PsetProperties
subject.import_pset_from_existing(pset, blender_props, None)
# Edit only "Bar", never touching "Foo"'s unit dropdown.
blender_props.properties["Bar"].metadata.float_value = 4.0
properties = _build_wrapped_properties_from_ui(blender_props)
ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties)
assert overridden_prop.Unit == length_ft # untouched override survives
assert overridden_prop.NominalValue.wrappedValue == 10.0
assert untouched_prop.NominalValue.wrappedValue == 4.0
class TestEditQtoRoundingLoopPreservesUnitWrappedValues(NewFile):
def test_run(self):
# Regression test for EditPset._execute()'s qto post-processing loop: it must reach
# into {"Unit": ..., "NominalValue": ...}-wrapped values to round them, rather than
# treating the whole dict as a bare float/int (which would zero it out).
properties = {
"Foo": {"NominalValue": 2.123456, "Unit": None},
"Bar": 3,
}
for key, value in properties.items():
if value is None:
continue
is_wrapped = isinstance(value, dict) and "Unit" in value
raw = value["NominalValue"] if is_wrapped else value
if raw is None:
continue
if isinstance(raw, float):
raw = round(raw, 4)
elif not isinstance(raw, int):
raw = 0
if is_wrapped:
value["NominalValue"] = raw
else:
properties[key] = raw
assert properties["Foo"]["NominalValue"] == 2.1235
assert properties["Foo"]["Unit"] is None
assert properties["Bar"] == 3
+36
View File
@@ -181,6 +181,42 @@ class TestGetCalculatedObjectQuantities(test.bim.bootstrap.NewFile):
assert quantities["NetVolume"] == 282.517
class TestGetTargetUnits(test.bim.bootstrap.NewFile):
def setup_file(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject", name="Test")
return ifc
def test_default_scene_state_returns_nothing(self):
self.setup_file()
assert subject.get_target_units() == {}
def test_setting_a_field_maps_it_to_its_measure_class(self):
ifc = self.setup_file()
metre = ifc.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
millimetre = ifc.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
ifcopenshell.api.unit.assign_unit(ifc, units=[metre])
props = tool.Qto.get_qto_props()
props.target_unit_length = str(millimetre.id())
assert subject.get_target_units() == {"IfcLengthMeasure": millimetre}
def test_untouched_fields_are_excluded(self):
ifc = self.setup_file()
metre = ifc.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
millimetre = ifc.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
gram = ifc.createIfcSIUnit(None, "MASSUNIT", None, "GRAM")
ifcopenshell.api.unit.assign_unit(ifc, units=[metre, gram])
props = tool.Qto.get_qto_props()
props.target_unit_length = str(millimetre.id())
props.target_unit_mass = "0" # explicitly left at "Default"
assert subject.get_target_units() == {"IfcLengthMeasure": millimetre}
class TestGetBaseQto(test.bim.bootstrap.NewFile):
def test_run(self):
ifc = ifcopenshell.file()
+86 -4
View File
@@ -24,7 +24,7 @@ import os
import types
from collections import defaultdict
from collections.abc import Iterable
from typing import Any, Literal, NamedTuple, Union, get_args
from typing import Any, Literal, NamedTuple, Optional, Union, get_args
import ifcopenshell
import ifcopenshell.api.pset
@@ -127,8 +127,61 @@ def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_inst
return results
def edit_qtos(ifc_file: ifcopenshell.file, results: ResultsDict) -> None:
"""Apply quantification results as quantity sets."""
def get_quantity_measures(rules: dict) -> dict[str, dict[str, str]]:
"""Statically derive each quantity's measure class from the rule set that defines it,
reading it straight from the calculator's own Function table (the same source the
calculator itself used to compute the value) -- not guessed from the quantity name.
:param rules: A rule set as accepted by :func:`quantify`, e.g. from `ifc5d.qto.rules`.
:return: `qto_name -> quantity_name -> measure class` (e.g. "IfcLengthMeasure"), matching
the keys used by `SI2ProjectUnitConverter.project_units`.
"""
measures: dict[str, dict[str, str]] = {}
for calculator_name, queries in rules.get("calculators", {}).items():
calculator = calculators[calculator_name]
for _entity_or_query, qtos in queries.items():
for qto_name, quantities in qtos.items():
for quantity_name, formula in quantities.items():
if not formula:
continue
function = calculator.functions.get(formula)
if function is None:
continue
measures.setdefault(qto_name, {})[quantity_name] = function.measure
return measures
def _reconvert(ifc_file: ifcopenshell.file, value: float, to_unit: ifcopenshell.entity_instance) -> float:
"""Re-express `value` (as computed by `SI2ProjectUnitConverter` -- the project's default
unit for its dimension, or, if the project has none, raw SI, mirroring `convert()`'s own
fallback below) in `to_unit`, which shares `to_unit`'s dimension (`UnitType`).
"""
unit_type = getattr(to_unit, "UnitType", None)
from_unit = ifcopenshell.util.unit.get_project_unit(ifc_file, unit_type) if unit_type else None
from_scale = ifcopenshell.util.unit.get_unit_scale(from_unit) if from_unit else 1.0 # already SI
return value * from_scale / ifcopenshell.util.unit.get_unit_scale(to_unit)
def edit_qtos(
ifc_file: ifcopenshell.file,
results: ResultsDict,
target_units: Optional[dict[str, ifcopenshell.entity_instance]] = None,
rules: Optional[dict] = None,
) -> None:
"""Apply quantification results as quantity sets.
:param target_units: Optional map of measure class (e.g. "IfcLengthMeasure", matching
`SI2ProjectUnitConverter.project_units`'s keys) to a unit to express *newly created*
quantities of that measure in, instead of the project default. Ignored unless `rules`
is also given (needed to resolve each quantity's measure class -- see
`get_quantity_measures`). Has no effect on quantities that already exist -- those are
always re-expressed in whatever Unit they already carry (see below), regardless of
`target_units`.
:param rules: The rule set used to produce `results` (the same object passed to
`quantify()`), used only to resolve `target_units` via `get_quantity_measures()`.
"""
quantity_measures = get_quantity_measures(rules) if (target_units and rules) else {}
for element, qtos in results.items():
for name, quantities in qtos.items():
qto = ifcopenshell.util.element.get_pset(element, name, should_inherit=False)
@@ -136,7 +189,36 @@ def edit_qtos(ifc_file: ifcopenshell.file, results: ResultsDict) -> None:
qto = ifc_file.by_id(qto["id"])
else:
qto = ifcopenshell.api.pset.add_qto(ifc_file, element, name)
ifcopenshell.api.pset.edit_qto(ifc_file, qto=qto, properties=quantities)
existing_by_name = {q.Name: q for q in (qto.Quantities or ())}
wrapped_quantities: dict[str, Any] = {}
for quantity_name, value in quantities.items():
existing_unit = getattr(existing_by_name.get(quantity_name), "Unit", None)
if existing_unit is not None:
# A quantity that already carries its own Unit override must be
# re-expressed in that unit, not overwritten with a value computed in
# the project default while the stale Unit label stays put.
wrapped_quantities[quantity_name] = {
"NominalValue": _reconvert(ifc_file, value, existing_unit),
"Unit": existing_unit,
}
continue
measure = quantity_measures.get(name, {}).get(quantity_name)
target_unit = target_units.get(measure) if (target_units and measure) else None
if target_unit is not None:
# Brand new quantity, proactively expressed in the chosen target unit.
wrapped_quantities[quantity_name] = {
"NominalValue": _reconvert(ifc_file, value, target_unit),
"Unit": target_unit,
}
continue
wrapped_quantities[quantity_name] = value # unchanged bare-float path
ifcopenshell.api.pset.edit_qto(ifc_file, qto=qto, properties=wrapped_quantities)
class SI2ProjectUnitConverter:
+139
View File
@@ -22,6 +22,7 @@ import ifcopenshell
import ifcopenshell.api.context
import ifcopenshell.api.root
import ifcopenshell.api.unit
import ifcopenshell.util.element
import pytest
import ifc5d.qto
@@ -90,3 +91,141 @@ class TestOpeningQuantities:
assert quantities["Depth"] == pytest.approx(0.3)
assert quantities["Area"] == pytest.approx(0.5)
assert quantities["Volume"] == pytest.approx(0.15)
class TestGetQuantityMeasures:
def test_resolves_measures_from_the_calculator_function_table(self):
measures = ifc5d.qto.get_quantity_measures(ifc5d.qto.rules["IFC4X3QtoBaseQuantities"])
assert measures["Qto_WallBaseQuantities"]["Length"] == "IfcLengthMeasure"
assert measures["Qto_WallBaseQuantities"]["NetWeight"] == "IfcMassMeasure"
class TestEditQtos:
def setup_method(self):
self.file = ifcopenshell.file(schema="IFC4X3")
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject", name="Test")
self.wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
def get_quantity(self, name: str) -> ifcopenshell.entity_instance:
pset = ifcopenshell.util.element.get_pset(self.wall, "Qto_WallBaseQuantities", should_inherit=False)
qto = self.file.by_id(pset["id"])
return next(q for q in qto.Quantities if q.Name == name)
def test_new_quantity_with_no_target_unit_is_a_bare_value(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}})
quantity = self.get_quantity("Length")
assert quantity.LengthValue == pytest.approx(5.0)
assert quantity.Unit is None
def test_existing_manual_unit_override_is_reconverted_not_left_stale(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}})
quantity = self.get_quantity("Length")
# Simulate a user picking a millimetre override via the per-property picker.
quantity.Unit = millimetre
quantity.LengthValue = 5000.0
# Re-running take-off recomputes the value in the project default (metres) again --
# this must not leave the recomputed metres value mislabeled as millimetres.
ifc5d.qto.edit_qtos(self.file, {self.wall: {"Qto_WallBaseQuantities": {"Length": 6.0}}})
quantity = self.get_quantity("Length")
assert quantity.Unit == millimetre
assert quantity.LengthValue == pytest.approx(6000.0)
def test_target_unit_applies_only_to_brand_new_quantities(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
rules = {"calculators": {"IfcOpenShell": {"IfcWall": {"Qto_WallBaseQuantities": {"Length": "net_get_x"}}}}}
ifc5d.qto.edit_qtos(
self.file,
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
target_units={"IfcLengthMeasure": millimetre},
rules=rules,
)
quantity = self.get_quantity("Length")
assert quantity.Unit == millimetre
assert quantity.LengthValue == pytest.approx(5000.0)
def test_target_units_are_ignored_without_rules(self):
metre = self.file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
ifcopenshell.api.unit.assign_unit(self.file, units=[metre])
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
# `rules` is required to resolve a quantity's measure class -- without it, target_units
# has nothing to key off, so brand new quantities fall back to today's bare-float path.
ifc5d.qto.edit_qtos(
self.file,
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
target_units={"IfcLengthMeasure": millimetre},
)
quantity = self.get_quantity("Length")
assert quantity.Unit is None
assert quantity.LengthValue == pytest.approx(5.0)
def test_reconvert_treats_a_missing_project_default_as_raw_si(self):
# No LENGTHUNIT is assigned to the project at all, so SI2ProjectUnitConverter.convert()
# would have left the calculated value as raw SI (metres) -- _reconvert must match.
millimetre = self.file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
rules = {"calculators": {"IfcOpenShell": {"IfcWall": {"Qto_WallBaseQuantities": {"Length": "net_get_x"}}}}}
ifc5d.qto.edit_qtos(
self.file,
{self.wall: {"Qto_WallBaseQuantities": {"Length": 5.0}}},
target_units={"IfcLengthMeasure": millimetre},
rules=rules,
)
quantity = self.get_quantity("Length")
assert quantity.LengthValue == pytest.approx(5000.0)
class TestEditQtosIntegration:
"""A real quantify() + edit_qtos() round trip, guarding against edit_qto's own
class-inference disagreeing with get_quantity_measures()'s notion of measure.
"""
def test_target_unit_produces_the_correct_quantity_class(self):
file = ifcopenshell.file(schema="IFC4X3")
ifcopenshell.api.root.create_entity(file, ifc_class="IfcProject", name="Test")
metre = file.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
sqm = file.createIfcSIUnit(None, "AREAUNIT", None, "SQUARE_METRE")
cum = file.createIfcSIUnit(None, "VOLUMEUNIT", None, "CUBIC_METRE")
ifcopenshell.api.unit.assign_unit(file, units=[metre, sqm, cum])
model = ifcopenshell.api.context.add_context(file, context_type="Model")
body = ifcopenshell.api.context.add_context(
file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model
)
wall = ifcopenshell.api.root.create_entity(file, ifc_class="IfcWall")
wall.ObjectPlacement = file.createIfcLocalPlacement(
None, file.createIfcAxis2Placement3D(file.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None)
)
profile = file.createIfcRectangleProfileDef("AREA", None, None, 5.0, 0.2)
position = file.createIfcAxis2Placement3D(file.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None)
solid = file.createIfcExtrudedAreaSolid(profile, position, file.createIfcDirection((0.0, 0.0, 1.0)), 3.0)
rep = file.createIfcShapeRepresentation(body, "Body", "SweptSolid", [solid])
wall.Representation = file.createIfcProductDefinitionShape(None, None, [rep])
millimetre = file.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
rules = ifc5d.qto.rules["IFC4X3QtoBaseQuantities"]
results = ifc5d.qto.quantify(file, {wall}, rules)
ifc5d.qto.edit_qtos(file, results, target_units={"IfcLengthMeasure": millimetre}, rules=rules)
pset = ifcopenshell.util.element.get_pset(wall, "Qto_WallBaseQuantities", should_inherit=False)
qto = file.by_id(pset["id"])
length = next(q for q in qto.Quantities if q.Name == "Length")
assert length.is_a("IfcQuantityLength")
assert length.Unit == millimetre
assert length.LengthValue == pytest.approx(5000.0)
@@ -23,6 +23,10 @@ import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.pset
# Sentinel distinguishing "no Unit dict was passed at all" from "a Unit dict was passed
# with Unit explicitly set to None" (i.e. explicitly clear an existing override).
_NO_UNIT = object()
def edit_pset(
file: ifcopenshell.file,
@@ -285,7 +289,7 @@ class Usecase:
f'Value "{self.settings["properties"][prop.Name]}" is not a valid value for enum property {prop.Name}.'
)
if unit:
if unit is not _NO_UNIT:
prop.Unit = unit
del self.settings["properties"][prop.Name]
return prop
@@ -310,7 +314,7 @@ class Usecase:
)
value = self.cast_value_to_primary_measure_type(value, primary_measure_type)
prop.NominalValue = self.file.create_entity(primary_measure_type, value)
if unit:
if unit is not _NO_UNIT:
prop.Unit = unit
del self.settings["properties"][prop.Name]
return prop
@@ -329,7 +333,7 @@ class Usecase:
# If it's not an entity, then it's a primitive data type
elif not value.is_entity():
kwargs = {"Name": name, "NominalValue": value}
if unit:
if unit is not None and unit is not _NO_UNIT:
kwargs["Unit"] = unit
properties.append(self.file.create_entity("IfcPropertySingleValue", **kwargs))
@@ -353,7 +357,7 @@ class Usecase:
"IfcPropertyListValue",
Name=name,
ListValues=[self.file.create_entity(ifc_class, v) for v in value],
Unit=unit,
Unit=unit if (unit is not None and unit is not _NO_UNIT) else None,
)
)
break
@@ -363,7 +367,7 @@ class Usecase:
"IFCPROPERTYENUMERATION",
Name=name,
EnumerationValues=pset_template.Enumerators.EnumerationValues,
**({"Unit": unit} if unit else {}),
**({"Unit": unit} if (unit is not None and unit is not _NO_UNIT) else {}),
)
prop_enum_value = self.file.create_entity(
"IFCPROPERTYENUMERATEDVALUE",
@@ -389,7 +393,7 @@ class Usecase:
value = self.cast_value_to_primary_measure_type(value, primary_measure_type)
nominal_value = self.file.create_entity(primary_measure_type, value)
args = {"Name": name, "NominalValue": nominal_value}
if unit:
if unit is not None and unit is not _NO_UNIT:
args["Unit"] = unit
properties.append(self.file.create_entity("IfcPropertySingleValue", **args))
@@ -479,12 +483,16 @@ class Usecase:
def unpack_unit_value(value_candidate):
"""
Returns tuple of the format: (Unit, NominalValue)
NOTE: Unit fallbacks to None
NOTE: Unit is the module-level _NO_UNIT sentinel when no Unit was specified at all
(bare value, or a dict without a "Unit" key), so that callers can distinguish "leave
the existing Unit untouched" from an explicit `{"Unit": None, ...}` (clear the
existing Unit override, falling back to the project default).
"""
if value_candidate is None:
return (None, None)
if isinstance(value_candidate, dict): # Custom IfcUnits can be passed in a dict along with the pset value
return (value_candidate["Unit"], value_candidate["NominalValue"])
return (value_candidate.get("Unit", _NO_UNIT), value_candidate["NominalValue"])
return (None, value_candidate)
return (_NO_UNIT, value_candidate)
@@ -136,10 +136,32 @@ def edit_qto(
return usecase.execute()
# Sentinel distinguishing "no Unit dict was passed at all" from "a Unit dict was passed
# with Unit explicitly set to None" (i.e. explicitly clear an existing override).
_NO_UNIT = object()
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
@staticmethod
def unpack_unit_value(value_candidate):
"""
Returns tuple of the format: (Unit, NominalValue)
NOTE: a dict value_candidate is ambiguous with the IfcPhysicalComplexQuantity spec
convention ({"Discrimination": ..., "HasQuantities": ...}) used elsewhere in this
module -- callers must check for that case (absence of a "Unit" key) before calling
this. Unit is the module-level _NO_UNIT sentinel when no Unit was specified at all
(bare value, or a dict without a "Unit" key), so that callers can distinguish "leave
the existing Unit untouched" from an explicit `{"Unit": None, ...}` (clear the
existing Unit override, falling back to the project default).
"""
if isinstance(value_candidate, dict) and "Unit" in value_candidate:
return (value_candidate["Unit"], value_candidate["NominalValue"])
return (_NO_UNIT, value_candidate)
def execute(self):
self.qto_idx = 5
if self.settings["qto"].is_a("IfcPhysicalComplexQuantity"):
@@ -173,16 +195,19 @@ class Usecase:
name = prop.Name
if value is None:
self.file.remove(prop)
elif prop.is_a("IfcPhysicalComplexQuantity") and isinstance(value, dict):
elif prop.is_a("IfcPhysicalComplexQuantity") and isinstance(value, dict) and "Unit" not in value:
prop.Discrimination = value.get("Discrimination", prop.Discrimination)
ifcopenshell.api.pset.edit_qto(self.file, qto=prop, properties=value["HasQuantities"])
elif prop.is_a("IfcPhysicalSimpleQuantity"):
unit, value = self.unpack_unit_value(value)
value = value.wrappedValue if isinstance(value, ifcopenshell.entity_instance) else value
# 3 IfcPhysicalSimpleQuantity.XXXValue
if self.file.schema == "IFC4X3" and prop.is_a("IfcQuantityCount"):
prop[3] = int(value)
else:
prop[3] = float(value)
if unit is not _NO_UNIT:
prop.Unit = unit
del self.settings["properties"][name]
def add_new_properties(self) -> list[ifcopenshell.entity_instance]:
@@ -190,21 +215,20 @@ class Usecase:
for name, value in self.settings["properties"].items():
if value is None:
continue
if isinstance(value, dict):
if isinstance(value, dict) and "Unit" not in value:
complex_qto = self.file.create_entity(
"IfcPhysicalComplexQuantity", Name=name, Discrimination=value["Discrimination"]
)
properties.append(complex_qto)
ifcopenshell.api.pset.edit_qto(self.file, qto=complex_qto, properties=value["HasQuantities"])
else:
unit, value = self.unpack_unit_value(value)
property_type = self.get_canonical_property_type(name, value)
value = value.wrappedValue if isinstance(value, ifcopenshell.entity_instance) else value
properties.append(
self.file.create_entity(
"IfcQuantity{}".format(property_type),
**{"Name": name, "{}Value".format(property_type): value},
)
)
kwargs = {"Name": name, "{}Value".format(property_type): value}
if unit is not None and unit is not _NO_UNIT:
kwargs["Unit"] = unit
properties.append(self.file.create_entity("IfcQuantity{}".format(property_type), **kwargs))
return properties
def extend_qto_with_new_properties(self, new_properties: list[ifcopenshell.entity_instance]) -> None:
+189 -39
View File
@@ -406,6 +406,51 @@ def get_named_dimensions(name):
return named_dimensions.get(name, (0, 0, 0, 0, 0, 0, 0))
def get_unit_dimensions(unit: ifcopenshell.entity_instance) -> tuple[int, int, int, int, int, int, int]:
"""Get the dimensional exponents of a unit, per IfcDimensionalExponents.
Supports IfcSIUnit, IfcConversionBasedUnit, IfcContextDependentUnit, and
IfcDerivedUnit (composed recursively from its elements).
:param unit: The unit to inspect.
:return: A 7-tuple of (Length, Mass, Time, ElectricCurrent,
ThermodynamicTemperature, AmountOfSubstance, LuminousIntensity).
"""
if unit.is_a("IfcDerivedUnit"):
dimensions = [0, 0, 0, 0, 0, 0, 0]
for element in unit.Elements:
element_dimensions = get_unit_dimensions(element.Unit)
for i in range(7):
dimensions[i] += element_dimensions[i] * element.Exponent
return tuple(dimensions)
if unit.is_a("IfcSIUnit"):
return get_si_dimensions(unit.Name.replace("METER", "METRE"))
return get_named_dimensions(getattr(unit, "UnitType", None))
def identify_unit_dimensions(unit: ifcopenshell.entity_instance) -> Union[str, None]:
"""Identify which named IfcUnitEnum type a unit's dimensions correspond to.
This is mainly useful for an IfcDerivedUnit that has no named
IfcDerivedUnitEnum match for its measure type, allowing it to still be
recognised as, e.g., a pressure unit by dimensional analysis alone.
Note that dimensionless quantities (e.g. plane angle, solid angle, or a
genuinely unitless value) are dimensionally indistinguishable, so this
heuristically returns the first zero-dimension match rather than
disambiguating them.
:param unit: The unit to identify.
:return: An uppercase IfcUnitEnum value, or None if no named type shares
the same dimensions.
"""
dimensions = get_unit_dimensions(unit)
for name, named in named_dimensions.items():
if named == dimensions:
return name
return None
def get_unit_assignment(ifc_file: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None]:
return ifc_file.by_type("IfcProject")[0].UnitsInContext
@@ -421,7 +466,16 @@ def cache_units(ifc_file: ifcopenshell.file) -> None:
"""
ifc_file.units = {}
if assignment := get_unit_assignment(ifc_file):
ifc_file.units = {u.UnitType: u for u in assignment.Units if getattr(u, "UnitType", None)}
all_units = assignment.Units or []
units = {u.UnitType: u for u in all_units if getattr(u, "UnitType", None)}
# As in get_project_unit(): a literal match always wins; an IfcDerivedUnit with no
# literal UnitType match is matched dimensionally instead, only to fill a gap.
for unit in all_units:
if unit.is_a("IfcDerivedUnit"):
dimension = identify_unit_dimensions(unit)
if dimension and dimension not in units:
units[dimension] = unit
ifc_file.units = units
def clear_unit_cache(ifc_file: ifcopenshell.file) -> None:
@@ -437,6 +491,10 @@ def get_project_unit(
) -> Union[ifcopenshell.entity_instance, None]:
"""Get the default project unit of a particular unit type
IfcDerivedUnit is matched first by a literal `UnitType` match, then, as a fallback, by
dimensional analysis (:func:`identify_unit_dimensions`), mirroring
:func:`get_candidate_units`.
:param ifc_file: The IFC file.
:param unit_type: The type of unit, taken from the list of IFC unit types,
such as "LENGTHUNIT".
@@ -448,9 +506,39 @@ def get_project_unit(
if units := ifc_file.units:
return units.get(unit_type, None)
if unit_assignment := get_unit_assignment(ifc_file):
dimensional_match = None
for unit in unit_assignment.Units or []:
if getattr(unit, "UnitType", None) == unit_type:
return unit
if dimensional_match is None and unit.is_a("IfcDerivedUnit") and identify_unit_dimensions(unit) == unit_type:
dimensional_match = unit
return dimensional_match
def get_candidate_units(ifc_file: ifcopenshell.file, unit_type: str) -> list[ifcopenshell.entity_instance]:
"""Get all units in the file usable as an override for `unit_type`.
Unlike :func:`get_project_unit`, this returns every matching unit defined
in the file (e.g. both an mm and an m IfcSIUnit might be present), not
just the one currently assigned as the project default.
IfcDerivedUnit is matched first by a literal `UnitType` match, then, as a
fallback, by dimensional analysis (:func:`identify_unit_dimensions`) --
that fallback only helps for the dimension families covered by
`named_dimensions` (the core `IfcUnitEnum` types); it won't match e.g.
`"MODULUSOFELASTICITYUNIT"` by dimension alone, only by literal `UnitType`.
:param ifc_file: The IFC file.
:param unit_type: The type of unit, taken from the list of IFC unit
types, such as "LENGTHUNIT", or an IfcDerivedUnitEnum value such as
"MODULUSOFELASTICITYUNIT".
:return: All matching IfcNamedUnit / IfcDerivedUnit entities in the file.
"""
candidates = [u for u in ifc_file.by_type("IfcNamedUnit") if getattr(u, "UnitType", None) == unit_type]
for unit in ifc_file.by_type("IfcDerivedUnit"):
if getattr(unit, "UnitType", None) == unit_type or identify_unit_dimensions(unit) == unit_type:
candidates.append(unit)
return candidates
def get_property_unit(
@@ -480,7 +568,8 @@ def get_property_unit(
entity = prop.wrapped_data.declaration().as_entity()
measure_class = entity.attribute_by_index(3).type_of_attribute().declared_type().name()
elif prop.is_a("IfcPropertySingleValue"):
measure_class = prop.NominalValue.is_a()
if value := prop.NominalValue:
measure_class = value.is_a()
elif prop.is_a("IfcPropertyEnumeratedValue"):
if prop.EnumerationReference:
if unit := prop.EnumerationReference.Unit:
@@ -622,6 +711,8 @@ def get_symbol_quantity_class(symbol: Optional[str] = None) -> QUANTITY_CLASS:
def get_unit_symbol(unit: ifcopenshell.entity_instance) -> str:
if unit.is_a("IfcDerivedUnit"):
return get_derived_unit_symbol(unit)
symbol: str = ""
if unit.is_a("IfcSIUnit"):
symbol += prefix_symbols.get(unit.Prefix, "")
@@ -631,6 +722,28 @@ def get_unit_symbol(unit: ifcopenshell.entity_instance) -> str:
return symbol
def get_derived_unit_symbol(unit: ifcopenshell.entity_instance) -> str:
"""Compose a unit symbol for an IfcDerivedUnit from its elements.
E.g. a derived unit of NEWTON / SQUARE_METRE composes to "N/m2".
:param unit: The IfcDerivedUnit.
:return: The composed symbol.
"""
numerator = []
denominator = []
for element in unit.Elements:
symbol = get_unit_symbol(element.Unit)
exponent = abs(element.Exponent)
if exponent != 1:
symbol += str(exponent)
(numerator if element.Exponent > 0 else denominator).append(symbol)
result = ".".join(numerator) or "1"
if denominator:
result += "/" + ".".join(denominator)
return result
def convert_unit(value: float, from_unit: ifcopenshell.entity_instance, to_unit: ifcopenshell.entity_instance) -> float:
"""Convert from one unit to another unit
@@ -684,6 +797,70 @@ def convert(value: float, from_prefix: Optional[str], from_unit: str, to_prefix:
return value
def get_named_unit_scale(unit: ifcopenshell.entity_instance) -> float:
"""Get the scale factor to convert a value in a named unit to SI units.
Supports IfcSIUnit and IfcConversionBasedUnit (including chains of
conversion-based units). Does not support IfcDerivedUnit -- see
:func:`get_derived_unit_scale` for that.
:param unit: The IfcNamedUnit.
:returns: The scale factor.
"""
scale = 1.0
while unit.is_a("IfcConversionBasedUnit"):
conversion_factor = unit.ConversionFactor
scale *= conversion_factor.ValueComponent.wrappedValue
unit = conversion_factor.UnitComponent
if unit.is_a("IfcSIUnit"):
prefix_multiplier = get_prefix_multiplier(unit.Prefix)
# An SI prefix attaches to the base unit symbol, and the prefixed
# symbol is raised to the power as a whole: dm3 = (dm)3 = 1e-3 m3,
# not 0.1 m3. For units whose dimensions are a pure power of length
# (METRE, SQUARE_METRE, CUBIC_METRE) the prefix multiplier must
# therefore be raised to the length exponent. Units with mixed or
# non-length dimensions (PASCAL, NEWTON, GRAM, ...) keep the linear
# multiplier, as there the prefix scales the derived unit itself.
# https://github.com/IfcOpenShell/IfcOpenShell/issues/9278
#
# Dimensions is looked up from si_dimensions by name rather than via
# unit.Dimensions (the schema-derived IfcDimensionalExponents), since
# the derived attribute isn't computed for SQLite-linked files and
# would return None there.
length_exponent, *other_exponents = get_si_dimensions(unit.Name.replace("METER", "METRE"))
if length_exponent > 0 and not any(other_exponents):
prefix_multiplier **= length_exponent
scale *= prefix_multiplier
return scale
def get_derived_unit_scale(unit: ifcopenshell.entity_instance) -> float:
"""Get the scale factor to convert a value in an IfcDerivedUnit to SI units.
:param unit: The IfcDerivedUnit.
:returns: The scale factor.
"""
scale = 1.0
for element in unit.Elements:
scale *= get_named_unit_scale(element.Unit) ** element.Exponent
return scale
def get_unit_scale(unit: ifcopenshell.entity_instance) -> float:
"""Get the scale factor to convert a value in `unit` to SI units.
Dispatches to :func:`get_derived_unit_scale` for IfcDerivedUnit, or
:func:`get_named_unit_scale` otherwise (IfcSIUnit / IfcConversionBasedUnit,
including chains).
:param unit: The unit to get the scale factor for.
:returns: The scale factor.
"""
if unit.is_a("IfcDerivedUnit"):
return get_derived_unit_scale(unit)
return get_named_unit_scale(unit)
def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUNIT") -> float:
"""Returns a unit scale factor to convert to and from IFC project units and SI units.
@@ -695,17 +872,17 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN
si_meters / unit_scale = ifc_project_length
:param ifc_file: The IFC file.
:param unit_type: The type of SI unit, defaults to "LENGTHUNIT"
:param unit_type: The type of SI unit, defaults to "LENGTHUNIT". This may
also be an IfcDerivedUnitEnum value (e.g. "MASSDENSITYUNIT") to
support project units defined as an IfcDerivedUnit.
:returns: The scale factor
"""
if (
type(ifc_file) is ifcopenshell.file
and unit_type
not in ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier)
.declaration_by_name("IfcUnitEnum")
.enumeration_items()
):
raise ValueError(f"Unit type {unit_type!r} does not name a valid type")
if type(ifc_file) is ifcopenshell.file and unit_type:
schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(ifc_file.schema_identifier)
valid_types = set(schema.declaration_by_name("IfcUnitEnum").enumeration_items())
valid_types |= set(schema.declaration_by_name("IfcDerivedUnitEnum").enumeration_items())
if unit_type not in valid_types:
raise ValueError(f"Unit type {unit_type!r} does not name a valid type")
# Currently we assume that all ifc projects must have IfcProject.
if not (projects := ifc_file.by_type("IfcProject")) or not (units := projects[0].UnitsInContext):
@@ -715,34 +892,7 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN
for unit in units.Units:
if getattr(unit, "UnitType", ...) != unit_type:
continue
while unit.is_a("IfcConversionBasedUnit"):
conversion_factor = unit.ConversionFactor
unit_scale *= conversion_factor.ValueComponent.wrappedValue
unit = conversion_factor.UnitComponent
if unit.is_a("IfcSIUnit"):
prefix_multiplier = get_prefix_multiplier(unit.Prefix)
# An SI prefix attaches to the base unit symbol, and the prefixed
# symbol is raised to the power as a whole: dm3 = (dm)3 = 1e-3 m3,
# not 0.1 m3. For units whose dimensions are a pure power of length
# (METRE, SQUARE_METRE, CUBIC_METRE) the prefix multiplier must
# therefore be raised to the length exponent. Units with mixed or
# non-length dimensions (PASCAL, NEWTON, GRAM, ...) keep the linear
# multiplier, as there the prefix scales the derived unit itself.
# https://github.com/IfcOpenShell/IfcOpenShell/issues/9278
dimensions = unit.Dimensions
length_exponent = dimensions.LengthExponent
if length_exponent > 0 and not any(
(
dimensions.MassExponent,
dimensions.TimeExponent,
dimensions.ElectricCurrentExponent,
dimensions.ThermodynamicTemperatureExponent,
dimensions.AmountOfSubstanceExponent,
dimensions.LuminousIntensityExponent,
)
):
prefix_multiplier **= length_exponent
unit_scale *= prefix_multiplier
unit_scale *= get_unit_scale(unit)
return unit_scale
@@ -188,6 +188,36 @@ class TestEditPsetIFC2X3(test.bootstrap.IFC2X3):
assert unit.Prefix == "GIGA"
assert unit.Name == "PASCAL"
def test_explicitly_clearing_a_propertys_unit_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="PRESSUREUNIT", Prefix="GIGA", Name="PASCAL")
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_pset(
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 30.0, "Unit": custom_unit}}
)
prop = pset.HasProperties[0]
assert prop.Unit == custom_unit
ifcopenshell.api.pset.edit_pset(
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 40.0, "Unit": None}}
)
assert prop.Unit is None
assert prop.NominalValue.wrappedValue == 40.0
def test_a_bare_value_does_not_disturb_an_existing_units_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="PRESSUREUNIT", Prefix="GIGA", Name="PASCAL")
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_pset(
self.file, pset=pset, properties={"MyCustom": {"NominalValue": 30.0, "Unit": custom_unit}}
)
prop = pset.HasProperties[0]
assert prop.Unit == custom_unit
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"MyCustom": 42.0})
assert prop.Unit == custom_unit
assert prop.NominalValue.wrappedValue == 42.0
def test_editing_properties_of_non_rooted_elements(self):
element = self.file.createIfcMaterial()
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foo_Bar")
@@ -18,6 +18,7 @@
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.unit
import test.bootstrap
@@ -150,3 +151,87 @@ class TestEditQto(test.bootstrap.IFC4):
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
assert qto.Quantities[0].Name == "MyLength"
assert qto.Quantities[0].LengthValue == 34
def test_adding_a_new_quantity_with_a_custom_unit(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
assert qto.Quantities[0].Name == "MyLength"
assert qto.Quantities[0].LengthValue == 30.0
assert qto.Quantities[0].Unit == custom_unit
def test_editing_an_existing_quantitys_unit(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(self.file, qto=qto, properties={"MyLength": 12.0})
quantity = qto.Quantities[0]
assert quantity.Unit is None
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
assert quantity.Unit == custom_unit
assert quantity.LengthValue == 30.0
def test_explicitly_clearing_a_quantitys_unit_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
quantity = qto.Quantities[0]
assert quantity.Unit == custom_unit
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 40.0, "Unit": None}}
)
assert quantity.Unit is None
assert quantity.LengthValue == 40.0
def test_a_bare_value_does_not_disturb_an_existing_units_override(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
custom_unit = self.file.createIfcSIUnit(UnitType="LENGTHUNIT", Prefix="MILLI", Name="METRE")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file, qto=qto, properties={"MyLength": {"NominalValue": 30.0, "Unit": custom_unit}}
)
quantity = qto.Quantities[0]
assert quantity.Unit == custom_unit
ifcopenshell.api.pset.edit_qto(self.file, qto=qto, properties={"MyLength": 42.0})
assert quantity.Unit == custom_unit
assert quantity.LengthValue == 42.0
def test_complex_quantity_editing_is_unaffected_by_the_unit_wrapper_convention(self):
# Regression guard: dict values are already overloaded to mean "this is an
# IfcPhysicalComplexQuantity spec" ({"Discrimination": ..., "HasQuantities": ...}).
# The new {"Unit": ..., "NominalValue": ...} convention must not be confused with it.
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
qto = ifcopenshell.api.pset.add_qto(self.file, product=element, name="Foo_Bar")
ifcopenshell.api.pset.edit_qto(
self.file,
qto=qto,
properties={"Layers": {"Discrimination": "layer", "HasQuantities": {"Width": 5.0}}},
)
qto = element.IsDefinedBy[0].RelatingPropertyDefinition
complex_qty = qto.Quantities[0]
assert complex_qty.is_a("IfcPhysicalComplexQuantity")
assert complex_qty.Name == "Layers"
assert complex_qty.Discrimination == "layer"
assert complex_qty.HasQuantities[0].Name == "Width"
assert complex_qty.HasQuantities[0].LengthValue == 5.0
# Editing it again (update_existing_property's complex-quantity branch) still works too.
ifcopenshell.api.pset.edit_qto(
self.file,
qto=qto,
properties={"Layers": {"Discrimination": "layer2", "HasQuantities": {"Width": 6.0}}},
)
assert complex_qty.Discrimination == "layer2"
assert complex_qty.HasQuantities[0].LengthValue == 6.0
@@ -16,7 +16,9 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import tempfile
from math import pi
from pathlib import Path
import numpy as np
import pytest
@@ -29,8 +31,10 @@ import ifcopenshell.api.unit
import ifcopenshell.util.element
import ifcopenshell.util.geolocation
import ifcopenshell.util.unit as subject
import ifcpatch
import test.bootstrap
from ifcopenshell.util.shape_builder import ShapeBuilder
from ifcpatch.recipes import Ifc2Sql
class TestMmToM:
@@ -90,6 +94,80 @@ class TestGetProjectUnit(test.bootstrap.IFC4):
assert subject.get_project_unit(self.file, "LENGTHUNIT", use_cache=True) == length2
assert self.file.units == {"LENGTHUNIT": length2, "AREAUNIT": area}
def test_area_and_volume_derived_from_length_are_matched_dimensionally(self):
# AREAUNIT/VOLUMEUNIT have no IfcDerivedUnitEnum member, so a project whose area/volume
# default is an IfcDerivedUnit has no literal UnitType match for either -- get_project_unit
# must still resolve them by dimensional analysis, like get_candidate_units already does.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
volume = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "volume-ish", {length: 3})
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area, volume])
assert subject.get_project_unit(self.file, "AREAUNIT") == area
assert subject.get_project_unit(self.file, "VOLUMEUNIT") == volume
def test_literal_unit_type_match_takes_priority_over_dimensional(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
literal_area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
derived_area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
ifcopenshell.api.unit.assign_unit(self.file, units=[length, literal_area, derived_area])
assert subject.get_project_unit(self.file, "AREAUNIT") == literal_area
def test_dimensional_fallback_also_applies_when_using_a_cache(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2})
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
assert subject.get_project_unit(self.file, "AREAUNIT", use_cache=True) == area
assert self.file.units["AREAUNIT"] == area
class TestGetCandidateUnits(test.bootstrap.IFC4):
def test_returns_only_units_matching_the_unit_type(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
m = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
candidates = subject.get_candidate_units(self.file, "LENGTHUNIT")
assert set(candidates) == {mm, m}
assert area not in candidates
def test_returns_all_matching_units_not_just_the_assigned_default(self):
# Unlike get_project_unit, which only returns the one assigned default.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
m = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[mm])
assert subject.get_project_unit(self.file, "LENGTHUNIT") == mm
assert set(subject.get_candidate_units(self.file, "LENGTHUNIT")) == {mm, m}
def test_derived_unit_matched_by_literal_unit_type(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
assert subject.get_candidate_units(self.file, "MODULUSOFELASTICITYUNIT") == [modulus]
def test_userdefined_derived_unit_matched_by_dimensional_fallback(self):
# No literal UnitType match (USERDEFINED), but dimensionally it's a pressure unit,
# and PRESSUREUNIT is one of the core families covered by named_dimensions.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
weird_pressure = ifcopenshell.api.unit.add_derived_unit(
self.file, "USERDEFINED", "pressure-ish", {force: 1, area: -1}
)
assert subject.get_candidate_units(self.file, "PRESSUREUNIT") == [weird_pressure]
def test_empty_when_nothing_matches(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
assert subject.get_candidate_units(self.file, "MASSUNIT") == []
class TestGetPropertyUnit(test.bootstrap.IFC4):
def test_no_unit(self):
@@ -119,6 +197,12 @@ class TestGetPropertyUnit(test.bootstrap.IFC4):
prop.Unit = length2
assert subject.get_property_unit(prop, self.file) == length2
def test_single_value_with_no_nominal_value(self):
# NominalValue is optional -- a property may be null. Must not crash.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
prop = self.file.createIfcPropertySingleValue(Name="Foo", NominalValue=None)
assert subject.get_property_unit(prop, self.file) is None
def test_enumerated_value(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
@@ -200,6 +284,17 @@ class TestCalculateUnitScale(test.bootstrap.IFC4):
ifcopenshell.api.unit.assign_unit(self.file, units=[angle])
assert subject.calculate_unit_scale(self.file, "PLANEANGLEUNIT") == pi / 180 * 0.001
def test_derived_units_are_considered(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT", prefix="MILLI")
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
ifcopenshell.api.unit.assign_unit(self.file, units=[modulus])
# AREAUNIT is a pure power of length, so its MILLI prefix is raised to
# the length exponent (2) per #9278: (1e-3)**2 = 1e-6, inverted by the
# derived unit's -1 exponent to give 1e6.
assert subject.calculate_unit_scale(self.file, "MODULUSOFELASTICITYUNIT") == pytest.approx(1_000_000.0)
def test_prefix_is_raised_to_the_length_exponent_for_area_and_volume(self):
# A prefixed square/cubic metre is (prefix-metre) squared/cubed:
# DECI SQUARE_METRE = dm2 = 1e-2 m2, DECI CUBIC_METRE = dm3 (litre) = 1e-3 m3.
@@ -226,6 +321,116 @@ class TestCalculateUnitScale(test.bootstrap.IFC4):
assert subject.calculate_unit_scale(self.file, "MASSUNIT") == pytest.approx(1000)
class TestCalculateUnitScaleOnLinkedFile(test.bootstrap.IFC4):
def test_run(self):
# Regression test: IfcSIUnit.Dimensions is a schema-*derived*
# attribute that isn't computed for SQLite-linked files (used for
# Bonsai's "linked project" large-model workflow), so it returns None
# there instead of an IfcDimensionalExponents entity. calculate_unit_scale()
# used to access unit.Dimensions.LengthExponent unconditionally for
# every IfcSIUnit, which crashed project loading for any linked file.
# See the PR discussion for a standalone reproduction script.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[length])
patcher = Ifc2Sql.Patcher(self.file, sql_type="SQLite")
patcher.patch()
tmp_file = Path(tempfile.mkstemp(suffix=".ifcsqlite")[1])
ifcpatch.write(patcher.get_output(), tmp_file)
try:
linked_file = ifcopenshell.open(str(tmp_file))
assert linked_file.by_type("IfcSIUnit")[0].Dimensions is None
assert subject.calculate_unit_scale(linked_file, "LENGTHUNIT") == 1.0
finally:
if isinstance(linked_file, ifcopenshell.sqlite):
linked_file.db.close()
tmp_file.unlink(missing_ok=True)
class TestGetNamedUnitScale(test.bootstrap.IFC4):
def test_prefix_is_raised_to_the_length_exponent_for_area_and_volume(self):
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT", prefix="DECI")
assert subject.get_named_unit_scale(area) == pytest.approx(0.1**2)
def test_prefix_stays_linear_for_units_that_are_not_a_pure_power_of_length(self):
pressure = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="PRESSUREUNIT", prefix="KILO")
assert subject.get_named_unit_scale(pressure) == pytest.approx(1000)
class TestGetDerivedUnitScale(test.bootstrap.IFC4):
def test_composes_scale_from_elements(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mass = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT", prefix="KILO")
volume = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="VOLUMEUNIT")
density = ifcopenshell.api.unit.add_derived_unit(self.file, "MASSDENSITYUNIT", None, {mass: 1, volume: -1})
assert subject.get_derived_unit_scale(density) == 1000.0
def test_unnamed_derived_unit_still_composes(self):
# A made-up "force per unit time" derived unit with no IfcDerivedUnitEnum match.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT", prefix="MILLI")
weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1})
assert subject.get_derived_unit_scale(weird) == pytest.approx(1 / 0.001)
class TestGetUnitScale(test.bootstrap.IFC4):
def test_dispatches_to_named_unit_scale_for_si_and_conversion_based_units(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mm = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
ft = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="foot")
assert subject.get_unit_scale(mm) == subject.get_named_unit_scale(mm)
assert subject.get_unit_scale(ft) == subject.get_named_unit_scale(ft)
def test_dispatches_to_derived_unit_scale_for_derived_units(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
mass = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT", prefix="KILO")
volume = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="VOLUMEUNIT")
density = ifcopenshell.api.unit.add_derived_unit(self.file, "MASSDENSITYUNIT", None, {mass: 1, volume: -1})
assert subject.get_unit_scale(density) == subject.get_derived_unit_scale(density)
class TestGetUnitSymbol(test.bootstrap.IFC4):
def test_derived_unit_composes_a_symbol(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
assert subject.get_unit_symbol(modulus) == "N/m2"
def test_unnamed_derived_unit_still_composes_a_symbol_without_crashing(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT")
weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1})
assert subject.get_unit_symbol(weird) == "N/s"
def test_context_dependent_userdefined_unit_is_not_shadowed_by_the_derived_unit_check(self):
# IfcContextDependentUnit (e.g. "each", "boxes") is a distinct entity
# from IfcDerivedUnit, so the is_a("IfcDerivedUnit") check added for
# derived-unit symbol composition must not shadow this fallback.
each = ifcopenshell.api.unit.add_context_dependent_unit(self.file, name="EACH")
assert subject.get_unit_symbol(each) == "EACH"
class TestIdentifyUnitDimensions(test.bootstrap.IFC4):
def test_matches_a_named_unit_type_by_dimension(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
modulus = ifcopenshell.api.unit.add_derived_unit(self.file, "MODULUSOFELASTICITYUNIT", None, {force: 1, area: -1})
assert subject.identify_unit_dimensions(modulus) == "PRESSUREUNIT"
def test_returns_none_for_no_match(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
force = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="FORCEUNIT")
time = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT")
weird = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "force per time", {force: 1, time: -1})
assert subject.identify_unit_dimensions(weird) is None
class TestFormatLength(test.bootstrap.IFC4):
def test_run(self):
assert subject.format_length(1, 1, decimal_places=0, unit_system="metric") == "1"