Merge remote-tracking branch 'origin/v0.8.0' into ifcviewer-wgpu

This commit is contained in:
Thomas Krijnen
2026-07-09 13:21:39 +02:00
373 changed files with 22411 additions and 4242 deletions
@@ -16,8 +16,6 @@
# 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 numpy as np
import ifcopenshell
import ifcopenshell.util.placement
from ifcopenshell import entity_instance
@@ -36,7 +34,7 @@ def update_fallback_position(file: ifcopenshell.file, lp: entity_instance):
if not lp.CartesianPosition:
lp.CartesianPosition = file.createIfcAxis2Placement3D(Location=file.createIfcCartesianPoint((0.0, 0.0, 0.0)))
p = np.array(ifcopenshell.util.placement.get_axis2placement(lp.RelativePlacement))
p = ifcopenshell.util.placement.get_local_placement(lp)
x = float(p[0, 3])
y = float(p[1, 3])
@@ -16,10 +16,15 @@
# 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.api.cost
import ifcopenshell.api.control
import ast
import operator
from typing import Any
import ifcopenshell.api.control
import ifcopenshell.api.cost
import ifcopenshell.util.element
def assign_cost_item_quantity(
@@ -27,6 +32,8 @@ def assign_cost_item_quantity(
cost_item: ifcopenshell.entity_instance,
products: list[ifcopenshell.entity_instance],
prop_name: str = "",
formula: str = "",
ifc_class: str = "IfcQuantityLength",
) -> None:
"""Adds a cost item quantity that is parametrically connected to a product
@@ -57,6 +64,12 @@ def assign_cost_item_quantity(
:param prop_name: The name of the quantity. If this is not specified,
then it is assumed that there is no calculated quantity, and the
number of objects are counted instead.
:param formula: The string that contains the formula
:param ifc_class: The quantity class of the calculated value if the formula is
specified. Can be ['IfcQuantityCount', 'IfcQuantityNumber',
'IfcQuantityLength', 'IfcQuantityArea', 'IfcQuantityVolume',
'IfcQuantityWeight', 'IfcQuantityTime']. Check
ifcopenshell.util.unit.QUANTITY_CLASS for more info.
:return: None
Example:
@@ -84,6 +97,18 @@ def assign_cost_item_quantity(
# item.
ifcopenshell.api.cost.assign_cost_item_quantity(model,
cost_item=item, products=[slab], prop_name="NetVolume")
# Now let's use the formula in order to calculate the quantity value.
# For example, let's say that a IfcWall has the reinfocement volume ratio
# stored in the Pset_ConcreteElementGeneral.ReinforcementVolumeRatio
# and of course it has also the gross volume stored in the
# Qto_WallBaseQuantities.GrossVolume. So we can add an IfcQuantity that stores the
# reinforcement volume calculated with reinfocement volume ratio * gross volume.
ifcopenshell.api.cost.assign_cost_item_quantity(model,
cost_item=item, products=[wall],
formula="Pset_ConcreteElementGeneral.ReinforcementVolumeRatio * NetVolume"
ifc_class="IfcQuantityVolume")
"""
usecase = Usecase()
usecase.file = file
@@ -91,6 +116,8 @@ def assign_cost_item_quantity(
"cost_item": cost_item,
"products": products or [],
"prop_name": prop_name,
"formula": formula,
"ifc_class" : ifc_class
}
return usecase.execute()
@@ -100,12 +127,50 @@ class Usecase:
settings: dict[str, Any]
def execute(self):
if self.settings["prop_name"]:
if self.settings["prop_name"] or self.settings["formula"]:
self.quantities = set(self.settings["cost_item"].CostQuantities or [])
for product in self.settings["products"]:
if product.is_a("IfcSpatialElement"):
continue
self.assign_cost_control(related_object=product, cost_item=self.settings["cost_item"])
if self.settings["formula"]:
tree = ast.parse(self.settings["formula"], mode = "eval")
collector = VariableExtractor()
collector.visit(tree)
variables = collector.variables
for variable in variables:
getter = self.get_value_from_pset if "." in variable else self.get_value_from_qset
value = getter(product, variable)
if value is None:
print(
f"WARNING: Variable '{variable}' in product '{product.Name}' "
f"is missing (None). Check Pset/Qset or property name."
)
elif value == 0:
print(
f"WARNING: Variable '{variable}' in product '{product.Name}' "
f"has value 0. Verify if this is correct."
)
evaluator = FormulaEvaluator(values)
result = evaluator.visit(tree.body)
new_quantity = None
for quantity in self.quantities:
if quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1: #Todo improve it
new_quantity = quantity
self.settings["ifc_class"] = quantity.is_a()
continue
if new_quantity is None:
new_quantity = self.file.create_entity(self.settings["ifc_class"], Name="Unnamed")
new_quantity.Formula = self.settings["formula"]
self.quantities.add(new_quantity)
new_quantity[3] = result
continue
if self.settings["prop_name"]:
if (
self.settings["cost_item"].CostQuantities
@@ -113,11 +178,30 @@ class Usecase:
):
continue
self.add_quantity_from_related_object(product)
if self.settings["prop_name"]:
if self.settings["prop_name"] or self.settings["formula"]:
self.settings["cost_item"].CostQuantities = list(self.quantities)
else:
self.update_cost_item_count()
def get_value_from_pset(
self,
product:ifcopenshell.entity_instance,
v: str,
) -> float:
pset_name = v.split(".")[0]
pset = ifcopenshell.util.element.get_pset(product, pset_name)
pset_property_name = v.split(".")[1]
return (pset or {}).get(pset_property_name,None)
def get_value_from_qset(
self,
product:ifcopenshell.entity_instance,
v: str,
) -> float:
qtos = ifcopenshell.util.element.get_psets(product, qtos_only = True)
quantities = next(iter(qtos.values()), {})
return (quantities or {}).get(v,None)
def assign_cost_control(
self, related_object: ifcopenshell.entity_instance, cost_item: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance:
@@ -158,3 +242,55 @@ class Usecase:
if not obj.is_a("IfcConstructionResource"):
count += 1
quantity[3] = count
OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
}
def build_full_name(node):
#used for variables with dots
parts = []
while isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
if isinstance(node, ast.Name):
parts.append(node.id)
return ".".join(reversed(parts))
class VariableExtractor(ast.NodeVisitor):
def __init__(self):
self.variables = set()
def visit_Name(self, node):
self.variables.add(node.id)
def visit_Attribute(self, node):
self.variables.add(build_full_name(node))
class FormulaEvaluator(ast.NodeVisitor):
def __init__(self, values):
self.values = values
def visit_BinOp(self, node):
left = self.visit(node.left)
right = self.visit(node.right)
return OPERATORS[type(node.op)](left, right)
def visit_Name(self, node):
return self.values[node.id]
def visit_Attribute(self, node):
return self.values[build_full_name(node)]
def visit_Constant(self, node):
return node.value
def generic_visit(self, node):
raise ValueError(f"Operation not permitted: {type(node).__name__}")
@@ -44,7 +44,7 @@ def regenerate_wall_representation(
length: float = 1.0,
height: float = 1.0,
angle: Optional[float] = None,
) -> ifcopenshell.entity_instance:
) -> Optional[ifcopenshell.entity_instance]:
"""
Regenerate the body representation of a wall taking into account connections.
@@ -94,7 +94,10 @@ def regenerate_wall_representation(
default height in SI units.
:param angle: If the wall doesn't already have a slope, this is the default
angle in radians. Left as none or 0 defines no slope.
:return: The newly generated body IfcShapeRepresentation
:return: The newly generated body IfcShapeRepresentation, or ``None`` if
the wall has no ``IfcMaterialLayerSet`` (the layer-set rebuild is the
only mode this function knows; without layers there is nothing to
regenerate and callers should leave the existing representation alone).
"""
return Regenerator(file).regenerate(wall, length=length, height=height, angle=angle)
@@ -24,6 +24,7 @@ import ifcopenshell.api.owner
import ifcopenshell.api.type
import ifcopenshell.guid
import ifcopenshell.util.element
import ifcopenshell.util.type
def assign_type(
@@ -189,6 +190,32 @@ class Usecase:
if not related_objects:
return
# The EXPRESS schema has no WHERE rule pairing RelatingType /
# RelatedObjects classes; the canonical class pairing per schema
# is a buildingSMART implementer agreement, enforced here.
allowed_occurrences = set(
ifcopenshell.util.type.get_applicable_entities(relating_type.is_a(), schema=self.file.schema)
)
# The implementer agreement map has no entry for the abstract
# IfcTypeProduct, which Bonsai uses for annotation types. The schema
# itself defines IfcTypeProduct.ApplicableOccurrence for exactly this
# purpose, so honor it when the leading class token is a valid entity.
if applicable_occurrence := getattr(relating_type, "ApplicableOccurrence", None):
occurrence_class = applicable_occurrence.split("/", 1)[0]
schema = ifcopenshell.schema_by_name(self.file.schema)
try:
schema.declaration_by_name(occurrence_class)
allowed_occurrences.add(occurrence_class)
except RuntimeError:
pass
mismatched_classes = sorted({o.is_a() for o in related_objects if o.is_a() not in allowed_occurrences})
if mismatched_classes:
raise TypeError(
f"{relating_type.is_a()} cannot type {', '.join(mismatched_classes)} "
f"in schema {self.file.schema} (allowed occurrence classes: "
f"{', '.join(sorted(allowed_occurrences)) or '<none>'})"
)
ifc2x3 = self.file.schema == "IFC2X3"
related_objects_set = set(related_objects)
if ifc2x3: