mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-21 04:32:23 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d506f06f95 | |||
| ebb498ddd0 | |||
| 7727a4fb4d | |||
| c0f967e81e | |||
| 3a3c00e6f6 | |||
| b8211143b1 | |||
| b401393fa8 | |||
| 439ea7bf9c | |||
| ae6eb4d18a | |||
| f05dd4aea5 | |||
| 7ed8584edc | |||
| c5ba22451f | |||
| 048242783e | |||
| 6f3acc84ee | |||
| e077390e3d | |||
| 80cc603932 |
@@ -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()
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -94,9 +94,15 @@ def list_functions(module: str) -> list[dict]:
|
||||
|
||||
|
||||
def function_docs(module: str, function: str) -> dict:
|
||||
"""Full documentation for a single API function.
|
||||
"""Show the full documentation for one ifcopenshell.api function.
|
||||
|
||||
Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
|
||||
Returns the summary and long description, every parameter with its type,
|
||||
default and description, and the return type. Read this before calling
|
||||
``run_api()`` so that parameter names and value types are correct.
|
||||
|
||||
:param module: API module name, for example ``'root'``.
|
||||
:param function: Function name within the module, for example
|
||||
``'create_entity'``.
|
||||
"""
|
||||
fn = _get_underlying_function(module, function)
|
||||
if fn is None:
|
||||
|
||||
@@ -14,10 +14,21 @@ def list_rules() -> list[dict[str, str]]:
|
||||
|
||||
|
||||
def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]:
|
||||
"""Run quantity take-off on the model using the named rule.
|
||||
"""Compute base quantities for elements and write them into the model.
|
||||
|
||||
Modifies the model in-place by adding/updating IfcElementQuantity psets.
|
||||
Returns a summary dict with ok, rule, and elements_quantified.
|
||||
This is a write operation: it derives lengths, areas and volumes from
|
||||
element geometry and adds or updates their ``IfcElementQuantity`` sets.
|
||||
It does not report a schedule — see ``ifcquery.schedule()`` for the
|
||||
construction programme and ``ifcquery.cost()`` for cost schedules. An
|
||||
unrecognised ``rule`` is reported as an error listing the rules that are
|
||||
available.
|
||||
|
||||
:param model: The in-memory IFC model. Modified in-place.
|
||||
:param rule: Quantity take-off rule set, for example
|
||||
``'IFC4QtoBaseQuantities'`` or ``'IFC4X3QtoBaseQuantities'``.
|
||||
:param selector: ifcopenshell selector restricting which elements are
|
||||
measured, e.g. ``'IfcWall'``. Omit to measure every ``IfcElement`` and
|
||||
``IfcSpace``.
|
||||
"""
|
||||
from ifc5d.qto import edit_qtos, quantify
|
||||
from ifc5d.qto import rules as rule_sets
|
||||
|
||||
+133
-45
@@ -35,6 +35,27 @@ from ifcquery import (
|
||||
from ifcquery import validate as validate_mod
|
||||
|
||||
|
||||
def _use_doc(source: Callable, extra: str = "") -> Callable:
|
||||
"""Decorator: copy `source`'s docstring onto the decorated method.
|
||||
|
||||
Keeps the query/edit logic in ``ifcquery``/``ifcedit`` as the single
|
||||
source of truth for what a delegating ``IfcSession`` method does, rather
|
||||
than maintaining a second prose description here. Only ``__doc__`` is
|
||||
copied — unlike `functools.wraps`, this leaves the method's own signature
|
||||
(and MCP tool schema derived from it) untouched.
|
||||
|
||||
:param extra: Optional session-specific note appended after `source`'s
|
||||
docstring, for the handful of methods that translate an argument
|
||||
(e.g. a JSON/MCP-friendly default) before delegating.
|
||||
"""
|
||||
|
||||
def decorator(fn: Callable) -> Callable:
|
||||
fn.__doc__ = (source.__doc__ or "").rstrip() + extra
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _jsonify(x: Any) -> Any:
|
||||
"""Convert IfcOpenShell objects / iterables into JSON-safe primitives."""
|
||||
if x is None or isinstance(x, (str, int, float, bool)):
|
||||
@@ -231,20 +252,47 @@ class IfcSession:
|
||||
return self.model
|
||||
|
||||
def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]:
|
||||
"""Create a new empty IFC model in memory."""
|
||||
"""Create a new empty IFC model in memory.
|
||||
|
||||
Replaces the model currently held by the session, discarding any unsaved
|
||||
edits. The new model has no file path of its own, so ``ifc_save`` must be
|
||||
given an explicit path.
|
||||
|
||||
:param schema: IFC schema version — ``IFC2X3``, ``IFC4``, ``IFC4X1``,
|
||||
``IFC4X2`` or ``IFC4X3`` — passed straight to ``ifcopenshell.file()``
|
||||
(default ``IFC4``). ``IFC4X3_ADD2`` is also accepted and, like
|
||||
``IFC4X3``, produces a model whose ``schema`` reports ``IFC4X3``.
|
||||
"""
|
||||
self.model = ifcopenshell.file(schema=schema)
|
||||
self.model_path = None
|
||||
return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)}
|
||||
|
||||
def ifc_load(self, path: str) -> str:
|
||||
"""Open an IFC file into memory. Returns confirmation string."""
|
||||
"""Open an IFC file from disk into the session.
|
||||
|
||||
Replaces the model currently held by the session, discarding any unsaved
|
||||
edits, and remembers the path so a later ``ifc_save`` can overwrite it.
|
||||
Call this before any query or edit method. Returns a confirmation string
|
||||
naming the schema version and entity count.
|
||||
|
||||
:param path: Filesystem path of the IFC file to open.
|
||||
"""
|
||||
self.model = ifcopenshell.open(path)
|
||||
self.model_path = path
|
||||
count = sum(1 for _ in self.model)
|
||||
return f"Loaded {path}: schema {self.model.schema}, {count} entities"
|
||||
|
||||
def ifc_save(self, path: str = "") -> str:
|
||||
"""Write the in-memory model to disk. Empty path overwrites the original file."""
|
||||
"""Write the in-memory model to disk.
|
||||
|
||||
Overwrites the target file without further confirmation. Edits made by
|
||||
``ifc_edit``, ``ifc_shape`` and ``ifc_quantify`` exist only in memory
|
||||
until this is called.
|
||||
|
||||
:param path: Destination path. Omit to overwrite the file the model was
|
||||
loaded from; this fails for a model created by ``ifc_new``, which has
|
||||
no original path.
|
||||
"""
|
||||
model = self._require_model()
|
||||
target = path if path else self.model_path
|
||||
if not target:
|
||||
@@ -253,7 +301,11 @@ class IfcSession:
|
||||
return f"Saved to {target}"
|
||||
|
||||
def ifc_reset(self) -> dict[str, Any]:
|
||||
"""Drop the in-memory model."""
|
||||
"""Discard the in-memory model.
|
||||
|
||||
Drops the model and its file path, throwing away any edits not already
|
||||
written with ``ifc_save``. Succeeds even when no model is loaded.
|
||||
"""
|
||||
self.model = None
|
||||
self.model_path = None
|
||||
return {"ok": True}
|
||||
@@ -261,39 +313,42 @@ class IfcSession:
|
||||
# -------------
|
||||
# Query tools
|
||||
# -------------
|
||||
@_use_doc(summary.summary)
|
||||
def ifc_summary(self) -> dict[str, Any]:
|
||||
"""Model overview: schema, entity counts, project info."""
|
||||
return summary.summary(self._require_model())
|
||||
|
||||
@_use_doc(tree.tree)
|
||||
def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements)."""
|
||||
return tree.tree(self._require_model())
|
||||
|
||||
@_use_doc(info.info)
|
||||
def ifc_info(self, element_id: int) -> dict[str, Any]:
|
||||
"""Deep inspection of an entity by step ID (attributes, psets, placement, type, material)."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||
return info.info(model, element)
|
||||
|
||||
@_use_doc(select.select)
|
||||
def ifc_select(self, query: str) -> list[dict[str, Any]]:
|
||||
"""Filter elements using ifcopenshell selector syntax.
|
||||
|
||||
Examples: ``IfcWall``, ``IfcWall, IfcColumn``, ``! IfcWall``,
|
||||
``IfcWall, Name = "My Wall"``, ``type = "Concrete Wall"``,
|
||||
``material = "Concrete"``.
|
||||
"""
|
||||
return select.select(self._require_model(), query)
|
||||
|
||||
@_use_doc(relations.relations)
|
||||
def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
raise IfcSessionError(f"Element #{element_id} not found.")
|
||||
return relations.relations(model, element, traverse=traverse if traverse else None)
|
||||
|
||||
@_use_doc(
|
||||
clash_mod.clash,
|
||||
extra=(
|
||||
"\n\nNote: this method takes a plain ``clearance: float`` rather than\n"
|
||||
'``clearance: float | None`` — ``0.0`` (the default) means "skip the\n'
|
||||
'clearance check", matching ``None`` in ``ifcquery.clash.clash()``.'
|
||||
),
|
||||
)
|
||||
def ifc_clash(
|
||||
self,
|
||||
element_id: int,
|
||||
@@ -301,7 +356,6 @@ class IfcSession:
|
||||
tolerance: float = 0.002,
|
||||
scope: str = "storey",
|
||||
) -> dict[str, Any]:
|
||||
"""Check element for geometric clashes. clearance=0.0 means no clearance check."""
|
||||
model = self._require_model()
|
||||
element = model.by_id(element_id)
|
||||
if element is None:
|
||||
@@ -314,33 +368,53 @@ class IfcSession:
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
@_use_doc(contexts_mod.contexts)
|
||||
def ifc_contexts(self) -> list[dict[str, Any]]:
|
||||
"""List all geometric representation contexts and subcontexts with their step IDs."""
|
||||
return contexts_mod.contexts(self._require_model())
|
||||
|
||||
@_use_doc(materials_mod.materials)
|
||||
def ifc_materials(self) -> list[dict[str, Any]]:
|
||||
"""List all materials and material sets (layers, constituents, profiles)."""
|
||||
return materials_mod.materials(self._require_model())
|
||||
|
||||
# ------------------------
|
||||
# Edit discovery + execute
|
||||
# ------------------------
|
||||
def ifc_list(self, module: str = "") -> list[dict]:
|
||||
"""List all API modules, or functions within a module. Empty module = all modules."""
|
||||
"""Discover the ifcopenshell.api functions available for editing.
|
||||
|
||||
With no argument returns every API module with its description,
|
||||
function names and function count. With a module name returns that
|
||||
module's functions, each with a one-line description and its
|
||||
parameters. This is the starting point for ``ifc_docs`` and
|
||||
``ifc_edit``; it inspects the installed ifcopenshell package and works
|
||||
without a model loaded.
|
||||
|
||||
:param module: API module name, for example ``'root'``, ``'geometry'``
|
||||
or ``'pset'``. Omit to list all modules.
|
||||
"""
|
||||
return list_functions(module) if module else list_modules()
|
||||
|
||||
@_use_doc(function_docs)
|
||||
def ifc_docs(self, function_path: str) -> dict:
|
||||
"""Show full documentation for an API function. Input format: 'module.function'."""
|
||||
module, function = function_path.split(".", 1)
|
||||
return function_docs(module, function)
|
||||
|
||||
def ifc_edit(self, function_path: str, params: Any = "{}") -> dict:
|
||||
"""Execute an ifcopenshell.api mutation.
|
||||
"""Run an ifcopenshell.api function to modify the model.
|
||||
|
||||
params may be:
|
||||
- JSON string
|
||||
- dict (from tool calling / JS)
|
||||
- JsProxy (handled upstream in embedded.py)
|
||||
This is the general-purpose edit method; use ``ifc_list`` and
|
||||
``ifc_docs`` first to find the function and its parameters. Changes
|
||||
are made to the in-memory model only, so ``ifc_save`` is needed to
|
||||
persist them. Returns ``{"ok": True, "result": ...}``, or
|
||||
``{"ok": False, "error": ...}`` when the function is unknown, a
|
||||
parameter cannot be converted, or the call raises.
|
||||
|
||||
:param function_path: ``'module.function'``, for example
|
||||
``'root.create_entity'``.
|
||||
:param params: Keyword arguments as a JSON string, a dict (tool
|
||||
calling) or a JsProxy (handled upstream in embedded.py). Pass
|
||||
entity references as integer step IDs, and arguments typed as an
|
||||
IFC file as a file path string.
|
||||
"""
|
||||
model = self._require_model()
|
||||
module, function = function_path.split(".", 1)
|
||||
@@ -359,28 +433,20 @@ class IfcSession:
|
||||
# ------------------------
|
||||
# Extended query + edit tools
|
||||
# ------------------------
|
||||
@_use_doc(validate_mod.validate)
|
||||
def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]:
|
||||
"""Validate the loaded model. Returns {'valid': bool, 'issues': [...]}."""
|
||||
return validate_mod.validate(self._require_model(), express_rules=express_rules)
|
||||
|
||||
@_use_doc(schedule.schedule)
|
||||
def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""List work schedules and nested tasks from the model.
|
||||
|
||||
max_depth limits subtask expansion (None = unlimited). At the cutoff,
|
||||
subtasks is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
return schedule.schedule(self._require_model(), max_depth=max_depth)
|
||||
|
||||
@_use_doc(cost_mod.cost)
|
||||
def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""List cost schedules and nested cost items from the model.
|
||||
|
||||
max_depth limits cost item expansion (None = unlimited). At the cutoff,
|
||||
subitems is replaced with {"truncated": True, "count": N}.
|
||||
"""
|
||||
return cost_mod.cost(self._require_model(), max_depth=max_depth)
|
||||
|
||||
@_use_doc(schema.schema)
|
||||
def ifc_schema(self, entity_type: str) -> dict[str, Any]:
|
||||
"""Return IFC class documentation for entity_type using the model's schema version."""
|
||||
return schema.schema(self._require_model(), entity_type)
|
||||
|
||||
def ifc_plot(
|
||||
@@ -456,18 +522,43 @@ class IfcSession:
|
||||
# Shape builder tools
|
||||
# ------------------------
|
||||
def ifc_shape_list(self) -> list[dict]:
|
||||
"""List all ShapeBuilder geometry methods with one-line descriptions and parameter names."""
|
||||
"""List the ShapeBuilder methods available for constructing geometry.
|
||||
|
||||
Returns every public ``ifcopenshell.util.shape_builder.ShapeBuilder``
|
||||
method with a one-line description and its parameter names, read
|
||||
directly from that class's own docstrings. Use it to find a method,
|
||||
then ``ifc_shape_docs`` for the details and ``ifc_shape`` to call it.
|
||||
Works without a model loaded.
|
||||
"""
|
||||
return _list_shape_methods()
|
||||
|
||||
def ifc_shape_docs(self, method: str) -> dict:
|
||||
"""Full documentation for a ShapeBuilder method: params, types, return value."""
|
||||
"""Show the full documentation for one ShapeBuilder method.
|
||||
|
||||
Returns the summary and long description, every parameter with its
|
||||
type and default, and the return type — read directly from
|
||||
``ShapeBuilder``'s own docstring. Read this before ``ifc_shape`` so
|
||||
that argument names and value shapes are correct. Works without a
|
||||
model loaded.
|
||||
|
||||
:param method: ShapeBuilder method name, for example ``'polyline'``,
|
||||
``'rectangle'`` or ``'extrude'``.
|
||||
"""
|
||||
return _shape_method_docs(method)
|
||||
|
||||
def ifc_shape(self, method: str, params: Any = "{}") -> dict:
|
||||
"""Call a ShapeBuilder method by name. Returns the created entity's step ID.
|
||||
"""Call a ShapeBuilder method to build geometry in the model.
|
||||
|
||||
params is a JSON string of keyword arguments. Pass entity references as integer
|
||||
step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]).
|
||||
The created entities are added to the in-memory model, so
|
||||
``ifc_save`` is needed to persist them. On success the result
|
||||
identifies the created entity by step ID and type; an unknown method
|
||||
or a failed call is reported as an error instead.
|
||||
|
||||
:param method: ShapeBuilder method name, as listed by
|
||||
``ifc_shape_list``.
|
||||
:param params: JSON string of keyword arguments. Pass entity
|
||||
references as integer step IDs and vectors as JSON arrays, e.g.
|
||||
``[1.0, 0.0, 0.0]``.
|
||||
"""
|
||||
model = self._require_model()
|
||||
|
||||
@@ -493,11 +584,8 @@ class IfcSession:
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
@_use_doc(run_quantify, extra="\n\nCall ``ifc_save`` afterwards to persist the result.")
|
||||
def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]:
|
||||
"""Run quantity take-off on the model using the named rule.
|
||||
|
||||
Modifies the model in-place; call ifc_save() after.
|
||||
"""
|
||||
model = self._require_model()
|
||||
return run_quantify(model, rule, selector=selector if selector else None)
|
||||
|
||||
|
||||
+29
-23
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
from ifcmcp.core import IfcSession
|
||||
@@ -23,6 +24,11 @@ def build_server() -> Any:
|
||||
|
||||
session = IfcSession()
|
||||
|
||||
def _tool(fn):
|
||||
"""Register a tool, taking its MCP description from the identically-named
|
||||
IfcSession method rather than duplicating it here."""
|
||||
return server.tool(description=inspect.getdoc(getattr(IfcSession, fn.__name__)))(fn)
|
||||
|
||||
server = FastMCP(
|
||||
name="ifc-mcp",
|
||||
instructions=(
|
||||
@@ -33,44 +39,44 @@ def build_server() -> Any:
|
||||
)
|
||||
|
||||
# ---- Lifecycle ----
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_new(schema: str = "IFC4") -> dict[str, Any]:
|
||||
return session.ifc_new(schema=schema)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_load(path: str) -> str:
|
||||
return session.ifc_load(path)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_save(path: str = "") -> str:
|
||||
return session.ifc_save(path)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_reset() -> dict[str, Any]:
|
||||
return session.ifc_reset()
|
||||
|
||||
# ---- Query ----
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_summary() -> dict[str, Any]:
|
||||
return session.ifc_summary()
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]:
|
||||
return session.ifc_tree()
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_info(element_id: int) -> dict[str, Any]:
|
||||
return session.ifc_info(element_id)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_select(query: str) -> list[dict[str, Any]]:
|
||||
return session.ifc_select(query)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
|
||||
return session.ifc_relations(element_id, traverse=traverse)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_clash(
|
||||
element_id: int,
|
||||
clearance: float = 0.0,
|
||||
@@ -84,58 +90,58 @@ def build_server() -> Any:
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_contexts() -> list[dict[str, Any]]:
|
||||
return session.ifc_contexts()
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_materials() -> list[dict[str, Any]]:
|
||||
return session.ifc_materials()
|
||||
|
||||
# ---- Edit ----
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_list(module: str = "") -> list[dict]:
|
||||
return session.ifc_list(module=module)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_docs(function_path: str) -> dict:
|
||||
return session.ifc_docs(function_path=function_path)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_edit(function_path: str, params: str = "{}") -> dict:
|
||||
return session.ifc_edit(function_path=function_path, params=params)
|
||||
|
||||
# ---- Extended query + edit ----
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_validate(express_rules: bool = False) -> dict[str, Any]:
|
||||
return session.ifc_validate(express_rules=express_rules)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
return session.ifc_schedule(max_depth=max_depth)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
return session.ifc_cost(max_depth=max_depth)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_schema(entity_type: str) -> dict[str, Any]:
|
||||
return session.ifc_schema(entity_type=entity_type)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]:
|
||||
return session.ifc_quantify(rule=rule, selector=selector)
|
||||
|
||||
# ---- Shape builder ----
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_shape_list() -> list[dict]:
|
||||
return session.ifc_shape_list()
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_shape_docs(method: str) -> dict:
|
||||
return session.ifc_shape_docs(method=method)
|
||||
|
||||
@server.tool()
|
||||
@_tool
|
||||
def ifc_shape(method: str, params: str = "{}") -> dict:
|
||||
return session.ifc_shape(method=method, params=params)
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ class TestServerRegistration:
|
||||
for name in expected:
|
||||
assert name in tools, f"Tool {name} not registered"
|
||||
|
||||
def test_all_tools_have_descriptions(self):
|
||||
server = build_server()
|
||||
tools = server._tool_manager.list_tools()
|
||||
missing = [t.name for t in tools if not (t.description or "").strip()]
|
||||
assert not missing, f"Tools with no description: {missing}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tool_fns():
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -79,7 +79,7 @@ from .get_layout_curve import get_layout_curve
|
||||
from .get_layout_segments import get_layout_segments
|
||||
from .get_mapped_segments import get_mapped_segments
|
||||
from .get_parent_alignment import get_parent_alignment
|
||||
from .get_referent_nest import get_referent_nest
|
||||
from .get_stationing_nest import get_stationing_nest
|
||||
from .get_vertical_layout import get_vertical_layout
|
||||
from .has_zero_length_segment import has_zero_length_segment
|
||||
from .layout_horizontal_alignment_by_pi_method import (
|
||||
@@ -89,8 +89,10 @@ 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
|
||||
from .util import *
|
||||
|
||||
__all__ = [
|
||||
@@ -124,14 +126,16 @@ __all__ = [
|
||||
"get_layout_curve",
|
||||
"get_layout_segments",
|
||||
"get_parent_alignment",
|
||||
"get_referent_nest",
|
||||
"get_stationing_nest",
|
||||
"get_vertical_layout",
|
||||
"has_zero_length_segment",
|
||||
"layout_horizontal_alignment_by_pi_method",
|
||||
"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",
|
||||
"get_mapped_segments",
|
||||
]
|
||||
|
||||
@@ -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})"
|
||||
+6
-5
@@ -26,9 +26,10 @@ _cant_callback = None
|
||||
|
||||
def register_referent_name_callback(horizontal=None, vertical=None, cant=None):
|
||||
"""
|
||||
Referents are automatically created at the start of each horizontal, vertical, and cant segment.
|
||||
The referents represent key points in the alignment layout such as Point of Curvature, Point of Tangent, and others.
|
||||
Different juristicions use different naming systems for these key points.
|
||||
Referents are created at the start of each horizontal, vertical, and cant segment by
|
||||
ifcopenshell.api.alignment.update_key_point_referents. The referents represent key points in the
|
||||
alignment layout such as Point of Curvature, Point of Tangent, and others. Different
|
||||
juristicions use different naming systems for these key points.
|
||||
|
||||
The referent name callback functions provide a customizable method for naming these referents. If a callback is registered,
|
||||
it is called when creating the referent name, otherwise the default naming is used.
|
||||
@@ -39,8 +40,8 @@ def register_referent_name_callback(horizontal=None, vertical=None, cant=None):
|
||||
|
||||
The callback function returns a string that is used in the referent name for the referent at the start of `segment`.
|
||||
The callback must accomodate the following cases:
|
||||
* prev_segment = None and segment != None - this indicates the last segment so the "End of Alignment" name is returned
|
||||
* prev_segment != None and segment == None - this indicates the first segment so the "Beginning of Alignment" name is returned
|
||||
* prev_segment = None and segment != None - this indicates the first segment so the "Beginning of Alignment" name is returned
|
||||
* prev_segment != None and segment == None - this indicates the last segment so the "End of Alignment" name is returned
|
||||
* prev_segment != None and segment != None - this indicates an intermediate segment so a name representitive of the transition is returned
|
||||
|
||||
Setting any or all of the callbacks to None causes the default naming to be used.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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/>.
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
def _sort_nest(nest: entity_instance, key: Callable) -> entity_instance:
|
||||
"""Sorts the RelatedObjects of an IfcRelNests in place, by an arbitrary key function."""
|
||||
nest.RelatedObjects = sorted(nest.RelatedObjects, key=key)
|
||||
return nest
|
||||
@@ -20,6 +20,7 @@ from typing import Optional
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
from ifcopenshell.api.alignment._sort_nest import _sort_nest
|
||||
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.guid
|
||||
@@ -114,7 +115,7 @@ def add_stationing_referent(
|
||||
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties)
|
||||
|
||||
nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
if nest is None:
|
||||
nest = file.createIfcRelNests(
|
||||
GlobalId=ifcopenshell.guid.new(), RelatingObject=alignment, RelatedObjects=(referent,)
|
||||
@@ -122,8 +123,6 @@ def add_stationing_referent(
|
||||
else:
|
||||
nest.RelatedObjects += (referent,)
|
||||
|
||||
nest.RelatedObjects = sorted(
|
||||
nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
|
||||
)
|
||||
_sort_nest(nest, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station"))
|
||||
|
||||
return referent
|
||||
|
||||
@@ -64,22 +64,22 @@ def create_representation(
|
||||
|
||||
# if the alignment is created without geometry it's stationing referent isn't related to the alignment geometry.
|
||||
# the stationing referent needs to be updated to have an IfcLinearPlacement that references the basis curve geometry
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
if (
|
||||
referent_nest
|
||||
and 0 < len(referent_nest.RelatedObjects)
|
||||
and referent_nest.RelatedObjects[0].ObjectPlacement
|
||||
and not referent_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
stationing_nest
|
||||
and 0 < len(stationing_nest.RelatedObjects)
|
||||
and stationing_nest.RelatedObjects[0].ObjectPlacement
|
||||
and not stationing_nest.RelatedObjects[0].ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
):
|
||||
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
|
||||
|
||||
if referent_nest.RelatedObjects[0].ObjectPlacement:
|
||||
if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location:
|
||||
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location)
|
||||
if referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection:
|
||||
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection)
|
||||
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement)
|
||||
file.remove(referent_nest.RelatedObjects[0].ObjectPlacement)
|
||||
if stationing_nest.RelatedObjects[0].ObjectPlacement:
|
||||
if stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location:
|
||||
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.Location)
|
||||
if stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection:
|
||||
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement.RefDirection)
|
||||
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement.RelativePlacement)
|
||||
file.remove(stationing_nest.RelatedObjects[0].ObjectPlacement)
|
||||
|
||||
lp = file.createIfcLinearPlacement(
|
||||
RelativePlacement=file.createIfcAxis2PlacementLinear(
|
||||
@@ -93,4 +93,4 @@ def create_representation(
|
||||
)
|
||||
)
|
||||
update_fallback_position(file, lp)
|
||||
referent_nest.RelatedObjects[0].ObjectPlacement = lp
|
||||
stationing_nest.RelatedObjects[0].ObjectPlacement = lp
|
||||
|
||||
@@ -67,8 +67,8 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
|
||||
print(dist_along) # 100.00
|
||||
"""
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
if referent_nest is None:
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
if stationing_nest is None:
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
return station - start_station
|
||||
|
||||
@@ -77,7 +77,7 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
|
||||
_distance_along_of_referent(referent),
|
||||
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"),
|
||||
)
|
||||
for referent in referent_nest.RelatedObjects
|
||||
for referent in stationing_nest.RelatedObjects
|
||||
]
|
||||
stations.sort(key=lambda entry: entry[0])
|
||||
|
||||
|
||||
+9
-3
@@ -20,12 +20,18 @@ import ifcopenshell
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
def get_referent_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance:
|
||||
def get_stationing_nest(file: ifcopenshell.file, alignment: entity_instance) -> entity_instance:
|
||||
"""
|
||||
Searches for the IfcRelNest that contains IfcReferent.
|
||||
Searches for the IfcRelNests that defines the alignment's stationing scheme.
|
||||
|
||||
The returned nest is nested to the IfcAlignment and its RelatedObjects contains only the
|
||||
IfcReferent(s) (PredefinedType="STATION") that establish the alignment's starting station and
|
||||
any station equations along it, as created by add_stationing_referent. It does not contain any
|
||||
other kind of referent (e.g. key-point referents from update_key_point_referents live in their
|
||||
own, separate IfcRelNests).
|
||||
|
||||
:param file:
|
||||
:param alignment: The IfcAlignment which hosts IfcReferent
|
||||
:param alignment: The IfcAlignment which hosts the stationing IfcReferent(s)
|
||||
:return: Returns the IfcRelNests or None
|
||||
"""
|
||||
if not alignment.is_a("IfcAlignment"):
|
||||
+108
@@ -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)
|
||||
@@ -0,0 +1,217 @@
|
||||
# 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/>.
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.guid
|
||||
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,
|
||||
)
|
||||
from ifcopenshell.api.alignment._sort_nest import _sort_nest
|
||||
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
||||
|
||||
|
||||
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."""
|
||||
for inverse in list(file.get_inverse(referent)):
|
||||
if inverse.is_a("IfcRelDefinesByProperties"):
|
||||
ifcopenshell.api.pset.remove_pset(file, product=referent, pset=inverse.RelatingPropertyDefinition)
|
||||
|
||||
object_placement = referent.ObjectPlacement
|
||||
if object_placement and file.get_total_inverses(object_placement) == 1:
|
||||
referent.ObjectPlacement = None
|
||||
ifcopenshell.util.element.remove_deep2(file, object_placement)
|
||||
|
||||
file.remove(referent) # also strips referent out of any IfcRelNests.RelatedObjects referencing it
|
||||
|
||||
|
||||
def _create_key_point_referent(
|
||||
file: ifcopenshell.file,
|
||||
alignment: entity_instance,
|
||||
curve: Optional[entity_instance],
|
||||
label: str,
|
||||
distance_along: float,
|
||||
station: float,
|
||||
) -> entity_instance:
|
||||
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
|
||||
object_placement = file.createIfcLinearPlacement(
|
||||
RelativePlacement=file.createIfcAxis2PlacementLinear(
|
||||
Location=file.createIfcPointByDistanceExpression(
|
||||
DistanceAlong=file.createIfcLengthMeasure(distance_along),
|
||||
OffsetLateral=None,
|
||||
OffsetVertical=None,
|
||||
OffsetLongitudinal=None,
|
||||
BasisCurve=curve,
|
||||
)
|
||||
),
|
||||
)
|
||||
update_fallback_position(file, object_placement)
|
||||
else:
|
||||
object_placement = file.createIfcLocalPlacement(
|
||||
PlacementRelTo=None,
|
||||
RelativePlacement=file.createIfcAxis2Placement2D(
|
||||
Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates)
|
||||
),
|
||||
)
|
||||
|
||||
name = f"{alignment.Name} {_get_key_point_tag(file, label, station)}"
|
||||
|
||||
referent = file.createIfcReferent(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
OwnerHistory=None,
|
||||
Name=name,
|
||||
Description=None,
|
||||
ObjectType=None,
|
||||
ObjectPlacement=object_placement,
|
||||
Representation=None,
|
||||
PredefinedType="POSITION",
|
||||
)
|
||||
|
||||
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
|
||||
|
||||
return referent
|
||||
|
||||
|
||||
def update_key_point_referents(
|
||||
file: ifcopenshell.file,
|
||||
layout: entity_instance,
|
||||
rel_nests: Optional[entity_instance] = None,
|
||||
clear: bool = False,
|
||||
) -> entity_instance:
|
||||
"""
|
||||
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.", ...), 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
|
||||
nested to `rel_nests`, an IfcRelNests distinct from the layout's segment nest (found via
|
||||
get_alignment_segment_nest) and from the alignment's stationing nest (found via
|
||||
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; 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.
|
||||
:return: the IfcRelNests, with RelatedObjects sorted ascending by Pset_Stationing.Station
|
||||
|
||||
Example:
|
||||
|
||||
.. code:: python
|
||||
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(model, horizontal)
|
||||
|
||||
Example, with custom labels for a jurisdiction that doesn't use the built-in abbreviations:
|
||||
|
||||
.. code:: python
|
||||
|
||||
def my_horizontal_labels(prev_segment, segment):
|
||||
if prev_segment is None:
|
||||
return "Start"
|
||||
if segment is None:
|
||||
return "End"
|
||||
return "Curve Point" # a name representative of the prev_segment -> segment transition
|
||||
|
||||
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 ends with "(Start)" instead of the default "(P.O.B.)"
|
||||
"""
|
||||
|
||||
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.")
|
||||
|
||||
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):
|
||||
_remove_referent(file, referent)
|
||||
rel_nests.RelatedObjects = ()
|
||||
|
||||
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:
|
||||
_sort_nest(
|
||||
rel_nests, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
|
||||
)
|
||||
return rel_nests
|
||||
|
||||
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")
|
||||
|
||||
new_referents = []
|
||||
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
|
||||
new_referents.append(_create_key_point_referent(file, alignment, curve, label, seg_distance_along, station))
|
||||
|
||||
if is_horizontal:
|
||||
distance_along += dp.SegmentLength
|
||||
else:
|
||||
distance_along = dp.StartDistAlong + dp.HorizontalLength
|
||||
|
||||
prev_segment = segment
|
||||
|
||||
label = _get_segment_start_point_label(prev_segment, None)
|
||||
station = start_station + distance_along
|
||||
new_referents.append(_create_key_point_referent(file, alignment, curve, label, distance_along, station))
|
||||
|
||||
rel_nests.RelatedObjects = tuple(rel_nests.RelatedObjects) + tuple(new_referents)
|
||||
_sort_nest(rel_nests, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station"))
|
||||
|
||||
return rel_nests
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ def test_add_segment_to_layout():
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create(file, "")
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert (
|
||||
len(referent_nest.RelatedObjects) == 1
|
||||
len(stationing_nest.RelatedObjects) == 1
|
||||
) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment
|
||||
|
||||
horizontal_alignment = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
@@ -75,8 +75,8 @@ def test_add_segment_to_layout():
|
||||
assert len(horizontal_alignment.IsNestedBy) == 1
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_alignment)
|
||||
assert len(segment_nest.RelatedObjects) == 2
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
assert len(referent_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert len(stationing_nest.RelatedObjects) == 1 # test this a second time to make sure that it is still true
|
||||
|
||||
|
||||
test_add_segment_to_layout()
|
||||
|
||||
@@ -39,8 +39,8 @@ def test_add_stationing_to_alignment():
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
referent = referent_nest.RelatedObjects[0]
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
referent = stationing_nest.RelatedObjects[0]
|
||||
|
||||
assert referent.PredefinedType == "STATION"
|
||||
assert referent.Name == "2+000.000"
|
||||
@@ -54,10 +54,10 @@ def test_add_stationing_to_alignment():
|
||||
file, "4+000.000", alignment, distance_along=1000.0, station=4000.0, incoming_station=3000.0
|
||||
)
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
assert len(referent_nest.RelatedObjects) == 2
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert len(stationing_nest.RelatedObjects) == 2
|
||||
|
||||
assert second_referent == referent_nest.RelatedObjects[1]
|
||||
assert second_referent == stationing_nest.RelatedObjects[1]
|
||||
|
||||
assert second_referent.PredefinedType == "STATION"
|
||||
assert second_referent.Name == "4+000.000"
|
||||
|
||||
@@ -36,11 +36,11 @@ def test_add_vertical_alignment():
|
||||
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
|
||||
assert len(layout_nest.RelatedObjects) == 1
|
||||
assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal")
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert (
|
||||
len(referent_nest.RelatedObjects) == 1
|
||||
len(stationing_nest.RelatedObjects) == 1
|
||||
) # the alignment creates the stationing nest and it has one referent to defined the stationing for the alignment
|
||||
assert referent_nest.RelatedObjects[0].is_a("IfcReferent")
|
||||
assert stationing_nest.RelatedObjects[0].is_a("IfcReferent")
|
||||
|
||||
curve = ifcopenshell.api.alignment.get_curve(alignment)
|
||||
assert curve.is_a("IfcCompositeCurve")
|
||||
|
||||
@@ -51,8 +51,8 @@ def test_create_by_pi_method():
|
||||
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
|
||||
assert len(layout_nest.RelatedObjects) == 2
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
assert len(referent_nest.RelatedObjects) == 1
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert len(stationing_nest.RelatedObjects) == 1
|
||||
|
||||
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
horizontal_segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal_layout)
|
||||
|
||||
@@ -46,9 +46,9 @@ def test_horizontal_layout_by_pi_method():
|
||||
|
||||
assert len(alignment.IsDecomposedBy) == 0 # no child alignments
|
||||
assert len(alignment.IsNestedBy) == 2
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
|
||||
assert referent_nest.RelatedObjects[0].is_a("IfcReferent")
|
||||
assert stationing_nest.RelatedObjects[0].is_a("IfcReferent")
|
||||
assert layout_nest.RelatedObjects[0].is_a("IfcAlignmentHorizontal")
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(layout_nest.RelatedObjects[0])
|
||||
assert len(segment_nest.RelatedObjects) == 3 # segments in horizontal layout
|
||||
|
||||
@@ -96,17 +96,37 @@ def callback_alignment():
|
||||
yield alignment
|
||||
|
||||
|
||||
def test_with_default_names(default_names_alignment):
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(None, default_names_alignment)
|
||||
def _label(name):
|
||||
return name.rsplit("(", 1)[1].rstrip(")")
|
||||
|
||||
expected = ["P.O.B", "P.C.", "P.T.", "P.O.E.", "V.P.O.B.", "P.V.C.", "P.V.T.", "V.P.O.E"]
|
||||
for r in referent_nest.RelatedObjects:
|
||||
assert [x in r.Name for x in expected]
|
||||
|
||||
def test_with_default_names(default_names_alignment):
|
||||
file = default_names_alignment.file
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(default_names_alignment)
|
||||
vertical = ifcopenshell.api.alignment.get_vertical_layout(default_names_alignment)
|
||||
|
||||
h_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
v_nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
|
||||
|
||||
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 [_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):
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(None, callback_alignment)
|
||||
file = callback_alignment.file
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(callback_alignment)
|
||||
vertical = ifcopenshell.api.alignment.get_vertical_layout(callback_alignment)
|
||||
|
||||
expected = ["A", "Q", "Z", "a", "q", "z"]
|
||||
for r in referent_nest.RelatedObjects:
|
||||
assert [x in r.Name for x in expected]
|
||||
h_nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
v_nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
|
||||
|
||||
expected_h = ["A", "Q", "Q", "Q", "Q", "Q", "Q", "Z"]
|
||||
expected_v = ["a", "q", "q", "q", "q", "q", "q", "q", "q", "z"]
|
||||
|
||||
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
|
||||
|
||||
+307
@@ -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()
|
||||
@@ -0,0 +1,402 @@
|
||||
# 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/>.
|
||||
|
||||
from collections import Counter
|
||||
|
||||
import pytest
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.alignment
|
||||
import ifcopenshell.util.element
|
||||
|
||||
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 _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)
|
||||
with pytest.raises(TypeError):
|
||||
ifcopenshell.api.alignment.update_key_point_referents(file, alignment)
|
||||
|
||||
|
||||
def test_default_rel_nests_created_when_none_provided():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
|
||||
assert nest.is_a("IfcRelNests")
|
||||
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_creates_separate_nest():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
segment_count_before = len(ifcopenshell.api.alignment.get_alignment_segment_nest(horizontal).RelatedObjects)
|
||||
|
||||
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(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)
|
||||
|
||||
# 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)
|
||||
|
||||
assert result.id() == rel_nests.id()
|
||||
assert result.RelatingObject == alignment
|
||||
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)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
old_referent_ids = [r.id() for r in nest.RelatedObjects]
|
||||
old_pset_ids = [r.IsDefinedBy[0].RelatingPropertyDefinition.id() for r in nest.RelatedObjects]
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest, clear=True)
|
||||
|
||||
assert len(nest.RelatedObjects) == 8
|
||||
for old_id in old_referent_ids + old_pset_ids:
|
||||
with pytest.raises(RuntimeError):
|
||||
file.by_id(old_id)
|
||||
|
||||
|
||||
def test_clear_false_appends_without_dedup():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
ifcopenshell.api.alignment.update_key_point_referents(file, horizontal, rel_nests=nest, clear=False)
|
||||
|
||||
assert len(nest.RelatedObjects) == 16
|
||||
counts = Counter(r.Name for r in nest.RelatedObjects)
|
||||
assert len(counts) == 8
|
||||
assert all(count == 2 for count in counts.values())
|
||||
|
||||
|
||||
def test_default_horizontal_labels_and_order():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
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 [_label(r.Name) for r in nest.RelatedObjects] == expected
|
||||
|
||||
stations = [_pset_station(r) for r in nest.RelatedObjects]
|
||||
assert stations == sorted(stations)
|
||||
assert stations[0] == 0.0
|
||||
|
||||
|
||||
def test_default_vertical_labels_and_order():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
vertical = ifcopenshell.api.alignment.get_vertical_layout(alignment)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, vertical)
|
||||
|
||||
expected = [
|
||||
"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 [_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
|
||||
# spot check the interior referents' stations against the segments' StartDistAlong directly
|
||||
for referent, segment in zip(nest.RelatedObjects[1:-1], real_segments[1:]):
|
||||
assert _pset_station(referent) == pytest.approx(segment.DesignParameters.StartDistAlong)
|
||||
|
||||
|
||||
def test_name_format():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
referent = nest.RelatedObjects[0]
|
||||
station = _pset_station(referent)
|
||||
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():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
curve = ifcopenshell.api.alignment.get_layout_curve(horizontal)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
|
||||
for referent in nest.RelatedObjects:
|
||||
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
location = referent.ObjectPlacement.RelativePlacement.Location
|
||||
assert location.is_a("IfcPointByDistanceExpression")
|
||||
assert location.BasisCurve == curve
|
||||
assert referent.ObjectPlacement.CartesianPosition is not None
|
||||
|
||||
first, last = nest.RelatedObjects[0], nest.RelatedObjects[-1]
|
||||
assert first.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == pytest.approx(0.0)
|
||||
assert last.ObjectPlacement.RelativePlacement.Location.DistanceAlong.wrappedValue == pytest.approx(
|
||||
_pset_station(last)
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_placement_when_layout_has_no_geometry():
|
||||
file = _new_file_no_context()
|
||||
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
ifcopenshell.api.alignment.layout_horizontal_alignment_by_pi_method(file, horizontal, COORDINATES, RADII)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
|
||||
expected_coordinates = alignment.ObjectPlacement.RelativePlacement.Location.Coordinates
|
||||
for referent in nest.RelatedObjects:
|
||||
assert referent.ObjectPlacement.is_a("IfcLocalPlacement")
|
||||
assert referent.ObjectPlacement.RelativePlacement.Location.Coordinates == expected_coordinates
|
||||
|
||||
|
||||
def test_cant_layout_boundary_labels():
|
||||
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)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, cant)
|
||||
|
||||
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
|
||||
# table (_get_segment_start_point_label.py) -- out of scope to fill in here.
|
||||
assert labels[1] == "xx"
|
||||
|
||||
stations = [_pset_station(r) for r in nest.RelatedObjects]
|
||||
assert stations == [0.0, 100.0, 150.0]
|
||||
|
||||
|
||||
def test_no_real_segments_produces_no_referents():
|
||||
file = _new_file_no_context()
|
||||
alignment = ifcopenshell.api.alignment.create(file, "A1", include_geometry=False)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
assert nest.RelatedObjects == ()
|
||||
|
||||
|
||||
def test_single_real_segment_produces_only_boundary_labels():
|
||||
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)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
labels = [_label(r.Name) for r in nest.RelatedObjects]
|
||||
assert labels == ["P.O.B.", "P.O.E."]
|
||||
|
||||
|
||||
def test_start_station_composes_for_child_alignment():
|
||||
file = _new_file()
|
||||
alignment = ifcopenshell.api.alignment.create(file, "A1", include_vertical=False, start_station=100.0)
|
||||
ifcopenshell.api.alignment.add_vertical_layout(file, alignment)
|
||||
ifcopenshell.api.alignment.add_vertical_layout(file, alignment) # forces the child-alignment split
|
||||
|
||||
child_alignment = alignment.IsDecomposedBy[0].RelatedObjects[-1]
|
||||
child_vertical = ifcopenshell.api.alignment.get_vertical_layout(child_alignment)
|
||||
|
||||
dp1 = file.createIfcAlignmentVerticalSegment(
|
||||
StartDistAlong=0.0,
|
||||
HorizontalLength=500.0,
|
||||
StartHeight=10.0,
|
||||
StartGradient=0.01,
|
||||
EndGradient=0.01,
|
||||
PredefinedType="CONSTANTGRADIENT",
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, child_vertical, dp1)
|
||||
|
||||
dp2 = file.createIfcAlignmentVerticalSegment(
|
||||
StartDistAlong=500.0,
|
||||
HorizontalLength=300.0,
|
||||
StartHeight=15.0,
|
||||
StartGradient=0.01,
|
||||
EndGradient=0.01,
|
||||
PredefinedType="CONSTANTGRADIENT",
|
||||
)
|
||||
ifcopenshell.api.alignment.create_layout_segment(file, child_vertical, dp2)
|
||||
|
||||
nest = ifcopenshell.api.alignment.update_key_point_referents(file, child_vertical)
|
||||
stations = [_pset_station(r) for r in nest.RelatedObjects]
|
||||
assert stations == pytest.approx([100.0, 600.0, 900.0])
|
||||
|
||||
|
||||
def test_returns_ifc_rel_nests():
|
||||
file = _new_file()
|
||||
alignment = _build_alignment(file)
|
||||
horizontal = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
|
||||
result = ifcopenshell.api.alignment.update_key_point_referents(file, horizontal)
|
||||
assert result.is_a("IfcRelNests")
|
||||
|
||||
|
||||
test_wrong_layout_type_raises_type_error()
|
||||
test_default_rel_nests_created_when_none_provided()
|
||||
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()
|
||||
test_default_vertical_labels_and_order()
|
||||
test_name_format()
|
||||
test_geometric_placement_when_layout_has_representation()
|
||||
test_fallback_placement_when_layout_has_no_geometry()
|
||||
test_cant_layout_boundary_labels()
|
||||
test_no_real_segments_produces_no_referents()
|
||||
test_single_real_segment_produces_only_boundary_labels()
|
||||
test_start_station_composes_for_child_alignment()
|
||||
test_returns_ifc_rel_nests()
|
||||
@@ -64,8 +64,8 @@ def test_vertical_layout_by_pi_method():
|
||||
layout_nest = ifcopenshell.api.alignment.get_alignment_layout_nest(alignment)
|
||||
assert len(layout_nest.RelatedObjects) == 2
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
assert len(referent_nest.RelatedObjects) == 1
|
||||
stationing_nest = ifcopenshell.api.alignment.get_stationing_nest(file, alignment)
|
||||
assert len(stationing_nest.RelatedObjects) == 1
|
||||
|
||||
segment_nest = ifcopenshell.api.alignment.get_alignment_segment_nest(vlayout)
|
||||
assert len(segment_nest.RelatedObjects) == 3
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -102,13 +102,26 @@ def clash(
|
||||
tolerance: float = 0.002,
|
||||
scope: str = "storey",
|
||||
) -> dict[str, Any]:
|
||||
"""Check element for geometric clashes against other elements.
|
||||
"""Check one element for geometric clashes against other elements.
|
||||
|
||||
Reports hard intersections and, optionally, violations of a required
|
||||
clearance. Returns the overall ``pass``, the ``scope`` actually used, a
|
||||
``checks`` block in which each clash names the other ``element``, the
|
||||
clash ``type``, the ``distance`` and the two closest points ``p1``/``p2``,
|
||||
and a de-duplicated flat ``elements`` list of everything involved.
|
||||
Geometry is computed for every element in scope, so this is slow on large
|
||||
models; ``pass`` is ``None`` with an ``error`` when the element has no
|
||||
usable geometry.
|
||||
|
||||
:param model: The IFC model.
|
||||
:param element: The element to check.
|
||||
:param clearance: Minimum clearance distance; if provided, runs clearance check.
|
||||
:param clearance: Minimum required clearance distance; when given, also
|
||||
runs the clearance check alongside the intersection check.
|
||||
:param tolerance: Intersection tolerance in meters (default 0.002).
|
||||
:param scope: Which elements to check against: "storey" or "all".
|
||||
:param scope: ``"storey"`` (default) checks only elements sharing the
|
||||
same spatial container; ``"all"`` checks every ``IfcElement``.
|
||||
``"storey"`` falls back to ``"all"`` when the element has no spatial
|
||||
container.
|
||||
:return: Dict with clash results suitable for JSON serialization.
|
||||
"""
|
||||
result: dict[str, Any] = {"element": _ref(element)}
|
||||
|
||||
@@ -26,10 +26,19 @@ def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None
|
||||
|
||||
|
||||
def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return a list of IfcCostSchedule entries with nested cost item trees.
|
||||
"""List the cost schedules: bills of quantities and their cost items.
|
||||
|
||||
max_depth limits how many levels of subitems are expanded (None = unlimited).
|
||||
At the cutoff level, subitems is replaced with {"truncated": True, "count": N}.
|
||||
Covers ``IfcCostSchedule`` only — this is the money dimension of the
|
||||
model; see ``schedule()`` in this module for the construction programme.
|
||||
Each cost item reports its cost ``values`` as ``formula`` label and
|
||||
``category`` pairs, together with its nested ``subitems``. Returns an
|
||||
empty list when the model has no cost schedules.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:param max_depth: Levels of cost item nesting to expand, counting root
|
||||
items as level 1. Past the cutoff ``subitems`` is replaced by a
|
||||
``{"truncated": True, "count": N}`` marker giving the number of items
|
||||
not expanded. ``None`` (default) expands to unlimited depth.
|
||||
"""
|
||||
result = []
|
||||
for cost_schedule in model.by_type("IfcCostSchedule"):
|
||||
|
||||
@@ -188,7 +188,16 @@ def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str
|
||||
|
||||
|
||||
def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]:
|
||||
"""Return deep inspection data for an element."""
|
||||
"""Inspect a single entity in depth.
|
||||
|
||||
Returns the entity's direct ``attributes`` plus, where present,
|
||||
``property_sets``, ``element_type``, ``material``, ``container``,
|
||||
``placement`` and ``geometry_summary``. Keys are omitted when the
|
||||
information is unavailable.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:param element: The entity to inspect.
|
||||
"""
|
||||
result: dict[str, Any] = {
|
||||
"id": element.id(),
|
||||
"type": element.is_a(),
|
||||
|
||||
@@ -22,7 +22,13 @@ import ifcopenshell
|
||||
|
||||
|
||||
def materials(model: ifcopenshell.file) -> list[dict]:
|
||||
"""Return all materials and material sets from the model.
|
||||
"""List the materials and material sets defined in the model.
|
||||
|
||||
Returns a single list covering ``IfcMaterial`` (with its category),
|
||||
``IfcMaterialLayerSet`` (each layer's name, thickness, material and
|
||||
ventilation flag), ``IfcMaterialConstituentSet`` (constituent names,
|
||||
materials and fractions) and ``IfcMaterialProfileSet`` (profile names and
|
||||
materials). Every entry carries the step ID of the material entity.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:return: List of dicts covering IfcMaterial, IfcMaterialLayerSet,
|
||||
|
||||
@@ -182,7 +182,22 @@ def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) -
|
||||
def relations(
|
||||
model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None
|
||||
) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Return relationships for an element, or hierarchy chain if traverse='up'."""
|
||||
"""Show how an element relates to the rest of the model.
|
||||
|
||||
By default returns a dict whose optional blocks are ``hierarchy`` (parent,
|
||||
container, aggregate, nest, filled void, voided element), ``children``
|
||||
(contained, parts, components, openings), ``type_relationship``,
|
||||
``groups``, ``systems``, ``zones``, ``material``, ``referenced_structures``
|
||||
and ``connections`` (connected to/from, ports), plus a de-duplicated flat
|
||||
``elements`` list of everything referenced. Blocks with nothing to report
|
||||
are omitted.
|
||||
|
||||
:param model: The IFC model.
|
||||
:param element: The element to examine.
|
||||
:param traverse: Set to ``'up'`` to instead return the chain of ancestors
|
||||
from the element to ``IfcProject`` as a flat list. Any other value
|
||||
gives the default behaviour.
|
||||
"""
|
||||
if traverse == "up":
|
||||
return _traverse_up(element)
|
||||
result = _all_relations(model, element)
|
||||
|
||||
@@ -37,10 +37,20 @@ def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, dep
|
||||
|
||||
|
||||
def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return a list of IfcWorkSchedule entries with nested task trees.
|
||||
"""List the construction programme: work schedules and their task trees.
|
||||
|
||||
max_depth limits how many levels of subtasks are expanded (None = unlimited).
|
||||
At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}.
|
||||
Covers ``IfcWorkSchedule`` only — this is the time dimension of the
|
||||
model; see ``cost()`` in this module for the money dimension. Each
|
||||
schedule lists its tasks recursively, and each task carries its scheduled
|
||||
``start`` and ``finish``, an ``is_milestone`` flag, the products it
|
||||
``outputs`` and its ``subtasks``. Returns an empty list when the model has
|
||||
no work schedules.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:param max_depth: Levels of subtask nesting to expand, counting root tasks
|
||||
as level 1. Past the cutoff ``subtasks`` is replaced by a
|
||||
``{"truncated": True, "count": N}`` marker giving the number of tasks
|
||||
not expanded. ``None`` (default) expands to unlimited depth.
|
||||
"""
|
||||
result = []
|
||||
for work_schedule in model.by_type("IfcWorkSchedule"):
|
||||
|
||||
@@ -8,7 +8,15 @@ import ifcopenshell.util.doc
|
||||
|
||||
|
||||
def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]:
|
||||
"""Return IFC class documentation for entity_type from model's schema version."""
|
||||
"""Look up the IFC documentation for an entity class.
|
||||
|
||||
Returns the class ``description``, its ``predefined_types``, per-attribute
|
||||
documentation and a ``spec_url``, resolved against the model's schema
|
||||
version. Returns an ``error`` key for an unknown class.
|
||||
|
||||
:param model: The in-memory IFC model, used only for its schema version.
|
||||
:param entity_type: IFC class name, for example ``'IfcWall'``.
|
||||
"""
|
||||
schema_name = model.schema
|
||||
try:
|
||||
doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type)
|
||||
|
||||
@@ -26,7 +26,15 @@ import ifcopenshell
|
||||
|
||||
|
||||
def summary(model: ifcopenshell.file) -> dict[str, Any]:
|
||||
"""Return a model overview with schema, element counts, and project info."""
|
||||
"""Summarise the model: schema, entity counts and project info.
|
||||
|
||||
Returns the ``schema`` version, ``total_entities``, and a ``project``
|
||||
block with the id, name and description of the first ``IfcProject``
|
||||
(omitted if the model has none). The count covers every entity in the
|
||||
file, not just physical elements.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
"""
|
||||
# Count elements by IFC type, sorted by count descending
|
||||
type_counter: Counter[str] = Counter()
|
||||
total = 0
|
||||
|
||||
@@ -59,7 +59,17 @@ def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]
|
||||
|
||||
|
||||
def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]:
|
||||
"""Return the spatial hierarchy tree starting from IfcProject."""
|
||||
"""Return the spatial hierarchy of the model as a nested tree.
|
||||
|
||||
Starts at ``IfcProject`` and descends through decomposition (site,
|
||||
building, storeys) and containment (the elements placed in each storey).
|
||||
Every node carries ``id``, ``type`` and ``name``; ``children`` holds
|
||||
decomposed sub-spaces and ``elements`` holds contained elements, and
|
||||
either key is omitted when empty. Returns a list when the file contains
|
||||
several projects, or an ``error`` key when it contains none.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
"""
|
||||
projects = model.by_type("IfcProject")
|
||||
if not projects:
|
||||
return {"error": "No IfcProject found in model"}
|
||||
|
||||
@@ -8,7 +8,16 @@ import ifcopenshell.validate
|
||||
|
||||
|
||||
def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]:
|
||||
"""Validate the model and return a dict with 'valid' bool and 'issues' list."""
|
||||
"""Validate the model against the IFC schema.
|
||||
|
||||
Returns ``valid`` together with a list of ``issues``, each carrying a
|
||||
``level`` and a ``message``. Worth running after a batch of edits and
|
||||
before writing the model back to disk.
|
||||
|
||||
:param model: The in-memory IFC model.
|
||||
:param express_rules: Also evaluate the schema's EXPRESS rules. Catches
|
||||
more problems but is considerably slower (default ``False``).
|
||||
"""
|
||||
logger = ifcopenshell.validate.json_logger()
|
||||
ifcopenshell.validate.validate(model, logger, express_rules=express_rules)
|
||||
issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements]
|
||||
|
||||
Reference in New Issue
Block a user