Compare commits

...

13 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
Robert Sigmundsson f05dd4aea5 Fix #9278. calculate_unit_scale raises the SI prefix to the length exponent for prefixed SQUARE_METRE/CUBIC_METRE units.
An SI prefix attaches to the base unit symbol and the prefixed symbol is
raised to the power as a whole: DECI CUBIC_METRE is dm3 = a litre = 1e-3 m3,
not 0.1 m3. The scale factor previously applied the prefix multiplier
linearly for all IfcSIUnits, inflating volumes x100 and areas x10 for such
declarations (produced e.g. by MagiCAD for Revit MEP exports).

Following the reviewer note in #9278, the exponent is taken from the
derived attribute IfcSIUnit.Dimensions rather than from substring matching
on the unit name: the multiplier is raised to LengthExponent only when the
unit's dimensions are a pure power of length, so prefixed derived units
(KILO PASCAL, MEGA NEWTON) and non-length units (KILO GRAM) correctly keep
the linear multiplier. This matches the exponent handling already present
in convert() and named_dimensions in the same module.

Adds regression tests for prefixed AREAUNIT/VOLUMEUNIT and for the
linear-prefix behaviour of PRESSUREUNIT/MASSUNIT.
2026-08-09 08:41:15 +02:00
Richard Brice 7ed8584edc Revised update_alignment_parameter_segment_tags to make EndTag optional 2026-08-08 10:35:20 -07:00
Richard Brice c5ba22451f Adds update_alignment_parameter_segment_tags function 2026-08-07 14:33:04 -07:00
Richard Brice 048242783e Updates update_key_point_referents to confirm to CT 4.1.4.4.3 2026-08-05 07:26:27 -07:00
29 changed files with 2373 additions and 188 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)
@@ -25,7 +25,7 @@ are automatically created and maintained.
Alignments are created with stationing referents. Each layout segment is assigned a position referent that informs about
the start point of the segment. An example is the point of curvature of a horizontal circular curve. The referent is
nested to the segment representing the circular arc and is named with a indicator of the position and the station, e.g. "P.C. (145+98.32)"
nested to the segment representing the circular arc and is named with the alignment name and an indicator of the position and the station, e.g. "MyAlignment 145+98.32 (P.C.)"
This API does not determine alignment parameters based on rules, such as minimum curve radius as a function of design speed or sight distance.
@@ -89,6 +89,7 @@ from .layout_vertical_alignment_by_pi_method import (
layout_vertical_alignment_by_pi_method,
)
from .name_segments import name_segments
from .update_alignment_parameter_segment_tags import update_alignment_parameter_segment_tags
from .update_end_point import update_end_point
from .update_fallback_position import update_fallback_position
from .update_key_point_referents import update_key_point_referents
@@ -132,6 +133,7 @@ __all__ = [
"layout_vertical_alignment_by_pi_method",
"name_segments",
"register_referent_name_callback",
"update_alignment_parameter_segment_tags",
"update_end_point",
"update_fallback_position",
"update_key_point_referents",
@@ -0,0 +1,30 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# 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 ifcopenshell
import ifcopenshell.util.alignment
def _get_key_point_tag(file: ifcopenshell.file, label: str, station: float) -> str:
"""
Builds the station-and-label text shared by update_alignment_parameter_segment_tags (used
directly as IfcAlignmentParameterSegment.StartTag/EndTag) and update_key_point_referents (used,
prefixed with the alignment name, as IfcReferent.Name): "<station> (<label>)", e.g.
"145+98.32 (P.O.B.)".
"""
return f"{ifcopenshell.util.alignment.station_as_string(file, station)} ({label})"
@@ -0,0 +1,108 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# 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 ifcopenshell
import ifcopenshell.api.alignment
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_key_point_tag import _get_key_point_tag
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
def update_alignment_parameter_segment_tags(
file: ifcopenshell.file, layout: entity_instance, label_end_tag: bool = False
) -> None:
"""
Sets IfcAlignmentParameterSegment.StartTag (and, optionally, EndTag) for every segment
transition in an alignment layout. Unlike update_key_point_referents, this does not create any
IfcReferent or IfcRelNests -- it only mutates the StartTag/EndTag string attributes already
present on each segment's DesignParameters.
Every real segment's StartTag is set to a computed tag describing the point where it begins,
using the same label-and-station format as update_key_point_referents' Name minus the alignment
name (via _get_key_point_tag), e.g. "145+98.32 (P.C.)". The first segment's StartTag comes from
the "Beginning of Alignment" boundary label.
EndTag is left untouched unless `label_end_tag` is True. When enabled, for each transition
between two consecutive segments, the outgoing segment's EndTag is set to the same tag as the
incoming segment's StartTag (they describe the same physical point), and the last segment's
EndTag is set from the "End of Alignment" boundary label.
Labels come from _get_segment_start_point_label -- if a callback has been registered via
register_referent_name_callback(), its output is used instead of the built-in labels, exactly as
in update_key_point_referents.
:param layout: IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
:param label_end_tag: if True, also sets EndTag on every real segment. If False (default),
EndTag is left untouched.
:return: None -- this function mutates segment.DesignParameters.StartTag/EndTag in place
Example:
.. code:: python
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(model, horizontal)
"""
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
if not layout.is_a() in expected_types:
raise TypeError(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
)
alignment = ifcopenshell.api.alignment.get_alignment(layout)
if alignment is None:
raise ValueError(f"{layout.is_a()} #{layout.id()} is not nested under an IfcAlignment.")
segments = list(ifcopenshell.api.alignment.get_layout_segments(layout))
if segments and ifcopenshell.api.alignment.has_zero_length_segment(layout):
segments = segments[:-1]
if not segments:
return
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
is_horizontal = layout.is_a("IfcAlignmentHorizontal")
distance_along = 0.0
prev_segment = None
for segment in segments:
dp = segment.DesignParameters
seg_distance_along = distance_along if is_horizontal else dp.StartDistAlong
label = _get_segment_start_point_label(prev_segment, segment)
station = start_station + seg_distance_along
tag = _get_key_point_tag(file, label, station)
dp.StartTag = tag
if prev_segment is not None and label_end_tag:
prev_segment.DesignParameters.EndTag = tag
if is_horizontal:
distance_along += dp.SegmentLength
else:
distance_along = dp.StartDistAlong + dp.HorizontalLength
prev_segment = segment
if label_end_tag:
label = _get_segment_start_point_label(prev_segment, None)
station = start_station + distance_along
prev_segment.DesignParameters.EndTag = _get_key_point_tag(file, label, station)
@@ -22,9 +22,9 @@ import ifcopenshell
import ifcopenshell.api.alignment
import ifcopenshell.api.pset
import ifcopenshell.guid
import ifcopenshell.util.alignment
import ifcopenshell.util.element
from ifcopenshell import entity_instance
from ifcopenshell.api.alignment._get_key_point_tag import _get_key_point_tag
from ifcopenshell.api.alignment._get_segment_start_point_label import (
_get_segment_start_point_label,
)
@@ -32,21 +32,6 @@ from ifcopenshell.api.alignment._sort_nest import _sort_nest
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
def _get_key_point_referent_nest(layout: entity_instance) -> Optional[entity_instance]:
"""
Searches layout.IsNestedBy for the IfcRelNests whose RelatedObjects are IfcReferent.
This is distinct from both get_stationing_nest (scoped to the parent IfcAlignment, and
specifically the STATION/station-equation nest) and get_alignment_segment_nest (the *segment*
nest that also lives on layout.IsNestedBy, holding IfcAlignmentSegment, never IfcReferent).
"""
for nest in layout.IsNestedBy:
for related_object in nest.RelatedObjects:
if related_object.is_a("IfcReferent"):
return nest
return None
def _remove_referent(file: ifcopenshell.file, referent: entity_instance) -> None:
"""Cleanly deletes a key-point IfcReferent: its Pset_Stationing, its ObjectPlacement (if
exclusively owned by it), and finally the referent itself."""
@@ -91,7 +76,7 @@ def _create_key_point_referent(
),
)
name = f"{label} ({ifcopenshell.util.alignment.station_as_string(file, station)})"
name = f"{alignment.Name} {_get_key_point_tag(file, label, station)}"
referent = file.createIfcReferent(
GlobalId=ifcopenshell.guid.new(),
@@ -120,7 +105,8 @@ def update_key_point_referents(
Creates IfcReferent key-point markers for every segment transition in an alignment layout.
Labels are derived from _get_segment_start_point_label (e.g. "P.C.", "P.T.", "P.O.B.",
"P.V.C.", ...), with the station appended, e.g. "P.C. (145+98.32)". Different jurisdictions use
"P.V.C.", ...), and combined with the alignment name and station to build the Name, e.g.
"MyAlignment 145+98.32 (P.C.)". Different jurisdictions use
different naming systems for these key points -- register_referent_name_callback() lets a
caller override the default horizontal/vertical/cant labeling before calling this function; if
a callback is registered, its output is used here instead of the built-in labels. Referents are
@@ -129,9 +115,11 @@ def update_key_point_referents(
get_stationing_nest) -- key-point referents never belong in either of those.
:param layout: IfcAlignmentHorizontal, IfcAlignmentVertical, or IfcAlignmentCant
:param rel_nests: an existing IfcRelNests to (re)populate. May live anywhere (e.g. the parent
IfcAlignment, the layout, or elsewhere) -- the caller decides. If omitted, an existing
referent-nest already on `layout` is reused, or a new one is created and related to `layout`.
:param rel_nests: an existing IfcRelNests to (re)populate; its RelatingObject must be the
IfcAlignment that nests `layout` (TypeError is raised otherwise). If omitted, a new
IfcRelNests is always created and related to that IfcAlignment -- there is no implicit
search for or reuse of a previously created nest. Callers who want to regenerate into an
existing nest must pass it back in explicitly via `rel_nests`.
:param clear: if True, deletes all IfcReferent currently in rel_nests.RelatedObjects (and their
Pset_Stationing) before regenerating. If False (default), new referents are appended to
whatever already exists -- no deduplication.
@@ -158,7 +146,7 @@ def update_key_point_referents(
ifcopenshell.api.alignment.register_referent_name_callback(horizontal=my_horizontal_labels)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest = ifcopenshell.api.alignment.update_key_point_referents(model, horizontal)
# nest.RelatedObjects[0].Name starts with "Start (" instead of the default "P.O.B. ("
# nest.RelatedObjects[0].Name ends with "(Start)" instead of the default "(P.O.B.)"
"""
expected_types = ["IfcAlignmentHorizontal", "IfcAlignmentVertical", "IfcAlignmentCant"]
@@ -167,12 +155,20 @@ def update_key_point_referents(
f"Expected entity type to be one of {[_ for _ in expected_types]}, instead received {layout.is_a()}"
)
if rel_nests is None:
rel_nests = _get_key_point_referent_nest(layout)
if rel_nests is None:
rel_nests = file.createIfcRelNests(
GlobalId=ifcopenshell.guid.new(), RelatingObject=layout, RelatedObjects=()
alignment = ifcopenshell.api.alignment.get_alignment(layout)
if alignment is None:
raise ValueError(f"{layout.is_a()} #{layout.id()} is not nested under an IfcAlignment.")
if rel_nests is not None:
if not rel_nests.RelatingObject.is_a("IfcAlignment"):
raise TypeError(
f"Expected rel_nests.RelatingObject to be IfcAlignment, instead received "
f"{rel_nests.RelatingObject.is_a()}"
)
else:
rel_nests = file.createIfcRelNests(
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=()
)
if clear:
for referent in list(rel_nests.RelatedObjects):
@@ -189,7 +185,6 @@ def update_key_point_referents(
)
return rel_nests
alignment = ifcopenshell.api.alignment.get_alignment(layout)
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
is_horizontal = layout.is_a("IfcAlignmentHorizontal")
@@ -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 -17
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,12 +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"):
unit_scale *= get_prefix_multiplier(unit.Prefix)
unit_scale *= get_unit_scale(unit)
return unit_scale
@@ -96,6 +96,10 @@ def callback_alignment():
yield alignment
def _label(name):
return name.rsplit("(", 1)[1].rstrip(")")
def test_with_default_names(default_names_alignment):
file = default_names_alignment.file
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(default_names_alignment)
@@ -107,8 +111,8 @@ def test_with_default_names(default_names_alignment):
expected_h = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
expected_v = ["V.P.O.B.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "P.V.C.", "P.V.T.", "V.P.O.E."]
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
assert [_label(r.Name) for r in h_nest.RelatedObjects] == expected_h
assert [_label(r.Name) for r in v_nest.RelatedObjects] == expected_v
def test_with_callbacks(callback_alignment):
@@ -122,7 +126,7 @@ def test_with_callbacks(callback_alignment):
expected_h = ["A", "Q", "Q", "Q", "Q", "Q", "Q", "Z"]
expected_v = ["a", "q", "q", "q", "q", "q", "q", "q", "q", "z"]
assert [r.Name.split(" (")[0] for r in h_nest.RelatedObjects] == expected_h
assert [r.Name.split(" (")[0] for r in v_nest.RelatedObjects] == expected_v
assert [_label(r.Name) for r in h_nest.RelatedObjects] == expected_h
assert [_label(r.Name) for r in v_nest.RelatedObjects] == expected_v
ifcopenshell.api.alignment.register_referent_name_callback(None, None, None) # reset global state
@@ -0,0 +1,307 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# 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 pytest
import ifcopenshell.api.alignment
import ifcopenshell.api.context
import ifcopenshell.api.unit
import ifcopenshell.util.alignment
COORDINATES = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
RADII = [1000.0, 1250.0, 950.0]
VPOINTS = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
LENGTHS = [1600.0, 1200.0, 2000.0, 800.0]
def _new_file():
file = ifcopenshell.file(schema="IFC4X3")
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
ifcopenshell.api.context.add_context(
file,
context_type="Model",
context_identifier="Axis",
target_view="MODEL_VIEW",
parent=geometric_representation_context,
)
return file
def _new_file_no_context():
file = ifcopenshell.file(schema="IFC4X3")
file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
ifcopenshell.api.unit.assign_unit(file, units=[length])
return file
def _build_alignment(file, start_station=0.0):
return ifcopenshell.api.alignment.create_by_pi_method(
file, "TestAlignment", COORDINATES, RADII, VPOINTS, LENGTHS, start_station
)
def _real_segments(layout):
segments = ifcopenshell.api.alignment.get_layout_segments(layout)
return segments[:-1] if ifcopenshell.api.alignment.has_zero_length_segment(layout) else segments
def _label(tag):
return tag.rsplit("(", 1)[1].rstrip(")")
def test_wrong_layout_type_raises_type_error():
file = _new_file()
alignment = _build_alignment(file)
with pytest.raises(TypeError):
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, alignment)
def test_not_nested_under_alignment_raises_value_error():
file = _new_file_no_context()
horizontal = file.createIfcAlignmentHorizontal(GlobalId=ifcopenshell.guid.new())
with pytest.raises(ValueError):
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
def test_returns_none():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
result = ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
assert result is None
def test_no_referents_or_rel_nests_created():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
referents_before = len(file.by_type("IfcReferent"))
rel_nests_before = len(file.by_type("IfcRelNests"))
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
assert len(file.by_type("IfcReferent")) == referents_before
assert len(file.by_type("IfcRelNests")) == rel_nests_before
def test_no_real_segments_leaves_tags_none():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
result = ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
assert result is None
segments = ifcopenshell.api.alignment.get_layout_segments(horizontal)
assert len(segments) == 1 # only the auto zero-length segment
assert segments[0].DesignParameters.StartTag is None
assert segments[0].DesignParameters.EndTag is None
def test_single_real_segment_produces_only_boundary_tags():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartTag=None,
EndTag=None,
StartPoint=file.createIfcCartesianPoint((0.0, 0.0)),
StartDirection=0.0,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=100.0,
GravityCenterLineHeight=None,
PredefinedType="LINE",
)
ifcopenshell.api.alignment.create_layout_segment(file, horizontal, design_parameters)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal, label_end_tag=True)
segments = _real_segments(horizontal)
assert len(segments) == 1
dp = segments[0].DesignParameters
assert _label(dp.StartTag) == "P.O.B."
assert _label(dp.EndTag) == "P.O.E."
def test_end_tag_not_labelled_by_default():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
design_parameters = file.createIfcAlignmentHorizontalSegment(
StartTag=None,
EndTag=None,
StartPoint=file.createIfcCartesianPoint((0.0, 0.0)),
StartDirection=0.0,
StartRadiusOfCurvature=0.0,
EndRadiusOfCurvature=0.0,
SegmentLength=100.0,
GravityCenterLineHeight=None,
PredefinedType="LINE",
)
ifcopenshell.api.alignment.create_layout_segment(file, horizontal, design_parameters)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
segments = _real_segments(horizontal)
assert len(segments) == 1
dp = segments[0].DesignParameters
assert _label(dp.StartTag) == "P.O.B."
assert dp.EndTag is None
def test_horizontal_tag_labels_and_adjacency():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal, label_end_tag=True)
segments = _real_segments(horizontal)
assert len(segments) == 7
start_labels = [_label(s.DesignParameters.StartTag) for s in segments]
end_labels = [_label(s.DesignParameters.EndTag) for s in segments]
assert start_labels == ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T."]
assert end_labels == ["P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
# every real segment has both tags set
assert all(s.DesignParameters.StartTag is not None for s in segments)
assert all(s.DesignParameters.EndTag is not None for s in segments)
# adjacent segments agree on the tag describing their shared transition point
for i in range(len(segments) - 1):
assert segments[i].DesignParameters.EndTag == segments[i + 1].DesignParameters.StartTag
def test_vertical_tag_labels_and_adjacency():
file = _new_file()
alignment = _build_alignment(file)
vertical = ifcopenshell.api.alignment.get_vertical_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, vertical, label_end_tag=True)
segments = _real_segments(vertical)
assert len(segments) == 9
start_labels = [_label(s.DesignParameters.StartTag) for s in segments]
end_labels = [_label(s.DesignParameters.EndTag) for s in segments]
assert start_labels == [
"V.P.O.B.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
]
assert end_labels == [
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"P.V.C.",
"P.V.T.",
"V.P.O.E.",
]
assert all(s.DesignParameters.StartTag is not None for s in segments)
assert all(s.DesignParameters.EndTag is not None for s in segments)
for i in range(len(segments) - 1):
assert segments[i].DesignParameters.EndTag == segments[i + 1].DesignParameters.StartTag
def test_cant_layout_boundary_tags():
file = _new_file_no_context()
alignment = ifcopenshell.api.alignment.create(file, "A1", include_cant=True, include_geometry=False)
cant = ifcopenshell.api.alignment.get_cant_layout(alignment)
dp1 = file.createIfcAlignmentCantSegment(
StartDistAlong=0.0,
HorizontalLength=100.0,
StartCantLeft=0.0,
EndCantLeft=0.0,
StartCantRight=0.0,
EndCantRight=0.0,
PredefinedType="CONSTANTCANT",
)
ifcopenshell.api.alignment.create_layout_segment(file, cant, dp1)
dp2 = file.createIfcAlignmentCantSegment(
StartDistAlong=100.0,
HorizontalLength=50.0,
StartCantLeft=0.0,
EndCantLeft=0.0,
StartCantRight=0.0,
EndCantRight=0.0,
PredefinedType="CONSTANTCANT",
)
ifcopenshell.api.alignment.create_layout_segment(file, cant, dp2)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, cant, label_end_tag=True)
segments = _real_segments(cant)
assert _label(segments[0].DesignParameters.StartTag) == "C.P.O.B."
assert _label(segments[-1].DesignParameters.EndTag) == "C.P.O.E."
# CONSTANTCANT -> CONSTANTCANT is currently an unfilled "xx" placeholder in the cant lookup
# table (_get_segment_start_point_label.py) -- out of scope to fill in here.
assert _label(segments[0].DesignParameters.EndTag) == "xx"
assert _label(segments[-1].DesignParameters.StartTag) == "xx"
def test_exact_tag_format():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
ifcopenshell.api.alignment.update_alignment_parameter_segment_tags(file, horizontal)
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
segments = _real_segments(horizontal)
assert segments[0].DesignParameters.StartTag == (
f"{ifcopenshell.util.alignment.station_as_string(file, start_station)} (P.O.B.)"
)
test_wrong_layout_type_raises_type_error()
test_not_nested_under_alignment_raises_value_error()
test_returns_none()
test_no_referents_or_rel_nests_created()
test_no_real_segments_leaves_tags_none()
test_single_real_segment_produces_only_boundary_tags()
test_end_tag_not_labelled_by_default()
test_horizontal_tag_labels_and_adjacency()
test_vertical_tag_labels_and_adjacency()
test_cant_layout_boundary_tags()
test_exact_tag_format()
@@ -66,6 +66,10 @@ def _pset_station(referent):
return ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station")
def _label(name):
return name.rsplit("(", 1)[1].rstrip(")")
def test_wrong_layout_type_raises_type_error():
file = _new_file()
alignment = _build_alignment(file)
@@ -82,13 +86,13 @@ def test_default_rel_nests_created_when_none_provided():
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert nest.is_a("IfcRelNests")
assert nest.RelatingObject == horizontal
assert nest.RelatingObject == alignment
assert nest.id() != segment_nest.id()
assert len(nest.RelatedObjects) == 8
assert all(r.is_a("IfcReferent") for r in nest.RelatedObjects)
def test_second_call_without_rel_nests_reuses_existing_nest():
def test_second_call_without_rel_nests_creates_separate_nest():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
@@ -97,18 +101,31 @@ def test_second_call_without_rel_nests_reuses_existing_nest():
nest1 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
nest2 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
assert nest1.id() == nest2.id()
assert len(nest2.RelatedObjects) == 16
assert nest1.id() != nest2.id()
assert len(nest1.RelatedObjects) == 8
assert len(nest2.RelatedObjects) == 8
segment_count_after = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects)
assert segment_count_after == segment_count_before
def test_passing_previous_nest_back_in_accumulates():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
nest1 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
nest2 = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest1)
assert nest1.id() == nest2.id()
assert len(nest2.RelatedObjects) == 16
def test_provided_rel_nests_is_used_as_is():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
# the nest may live anywhere the caller chooses, e.g. hung off the parent IfcAlignment
# rel_nests.RelatingObject must be the IfcAlignment that nests `layout`
rel_nests = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=())
result = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=rel_nests)
@@ -118,6 +135,17 @@ def test_provided_rel_nests_is_used_as_is():
assert len(result.RelatedObjects) == 8
def test_provided_rel_nests_with_wrong_relating_object_raises_type_error():
file = _new_file()
alignment = _build_alignment(file)
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
rel_nests = file.createIfcRelNests(GlobalId=ifcopenshell.guid.new(), RelatingObject=horizontal, RelatedObjects=())
with pytest.raises(TypeError):
ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=rel_nests)
def test_clear_true_removes_old_referents_and_psets():
file = _new_file()
alignment = _build_alignment(file)
@@ -157,7 +185,7 @@ def test_default_horizontal_labels_and_order():
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
expected = ["P.O.B.", "P.C.", "P.T.", "P.C.", "P.T.", "P.C.", "P.T.", "P.O.E."]
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
assert [_label(r.Name) for r in nest.RelatedObjects] == expected
stations = [_pset_station(r) for r in nest.RelatedObjects]
assert stations == sorted(stations)
@@ -183,7 +211,7 @@ def test_default_vertical_labels_and_order():
"P.V.T.",
"V.P.O.E.",
]
assert [r.Name.split(" (")[0] for r in nest.RelatedObjects] == expected
assert [_label(r.Name) for r in nest.RelatedObjects] == expected
segments = ifcopenshell.api.alignment.get_layout_segments(vertical)
real_segments = segments[:-1] if ifcopenshell.api.alignment.has_zero_length_segment(vertical) else segments
@@ -200,7 +228,7 @@ def test_name_format():
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
referent = nest.RelatedObjects[0]
station = _pset_station(referent)
assert referent.Name == f"P.O.B. ({ifcopenshell.util.alignment.station_as_string(file, station)})"
assert referent.Name == f"{alignment.Name} {ifcopenshell.util.alignment.station_as_string(file, station)} (P.O.B.)"
def test_geometric_placement_when_layout_has_representation():
@@ -268,7 +296,7 @@ def test_cant_layout_boundary_labels():
nest = ifcopenshell.api.alignment.update_key_point_referents(file, cant)
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
labels = [_label(r.Name) for r in nest.RelatedObjects]
assert labels[0] == "C.P.O.B."
assert labels[-1] == "C.P.O.E."
# CONSTANTCANT -> CONSTANTCANT is currently an unfilled "xx" placeholder in the cant lookup
@@ -307,7 +335,7 @@ def test_single_real_segment_produces_only_boundary_labels():
ifcopenshell.api.alignment.create_layout_segment(file, horizontal, design_parameters)
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
labels = [r.Name.split(" (")[0] for r in nest.RelatedObjects]
labels = [_label(r.Name) for r in nest.RelatedObjects]
assert labels == ["P.O.B.", "P.O.E."]
@@ -356,8 +384,10 @@ def test_returns_ifc_rel_nests():
test_wrong_layout_type_raises_type_error()
test_default_rel_nests_created_when_none_provided()
test_second_call_without_rel_nests_reuses_existing_nest()
test_second_call_without_rel_nests_creates_separate_nest()
test_passing_previous_nest_back_in_accumulates()
test_provided_rel_nests_is_used_as_is()
test_provided_rel_nests_with_wrong_relating_object_raises_type_error()
test_clear_true_removes_old_referents_and_psets()
test_clear_false_appends_without_dedup()
test_default_horizontal_labels_and_order()
@@ -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,152 @@ 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.
# https://github.com/IfcOpenShell/IfcOpenShell/issues/9278
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
area.Prefix = "DECI"
volume = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="VOLUMEUNIT")
volume.Prefix = "DECI"
ifcopenshell.api.unit.assign_unit(self.file, units=[area, volume])
assert subject.calculate_unit_scale(self.file, "AREAUNIT") == pytest.approx(0.1**2)
assert subject.calculate_unit_scale(self.file, "VOLUMEUNIT") == pytest.approx(0.1**3)
def test_prefix_stays_linear_for_units_that_are_not_a_pure_power_of_length(self):
# For derived and non-length SI units the prefix scales the unit itself:
# KILO PASCAL = 1e3 Pa, KILO GRAM = 1e3 g.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
pressure = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="PRESSUREUNIT")
pressure.Prefix = "KILO"
mass = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT")
mass.Prefix = "KILO"
ifcopenshell.api.unit.assign_unit(self.file, units=[pressure, mass])
assert subject.calculate_unit_scale(self.file, "PRESSUREUNIT") == pytest.approx(1000)
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):