diff --git a/src/bonsai/bonsai/bim/data/assets/default.css b/src/bonsai/bonsai/bim/data/assets/default.css
index 1070bc8e91..68307f4c3f 100644
--- a/src/bonsai/bonsai/bim/data/assets/default.css
+++ b/src/bonsai/bonsai/bim/data/assets/default.css
@@ -20,6 +20,8 @@
* { stroke-linecap: round; stroke-linejoin: round; }
text, tspan { /* 2.5mm */ fill: black; stroke: none; font-family: 'OpenGost Type B TT', 'DejaVu Sans Condensed', 'Liberation Sans', 'Arial Narrow', 'Arial'; font-size: 4.13px; }
+a text, a tspan { fill: blue !important; text-decoration: underline;}
+a:hover { cursor: pointer; }
.cut { fill: black; stroke: black; stroke-linecap: 'round'; stroke-width: 0.35; fill-rule: evenodd; }
.projection { fill: white; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
.surface { stroke: none; fill: #fff; fill-rule: evenodd; }
diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py
index eb5779d4af..1578f4eeed 100644
--- a/src/bonsai/bonsai/bim/module/drawing/__init__.py
+++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py
@@ -65,6 +65,17 @@ classes = (
operator.EnableEditingText,
operator.ExcludeAnnotation,
operator.ExpandSheet,
+ operator.ToggleElementValuesPanel,
+ operator.ToggleElementValuesCategory,
+ operator.SelectElementValues,
+ operator.InsertFormattedLiteralPopup,
+ operator.AddElementValueRow,
+ operator.RemoveElementValueRow,
+ operator.ElementValueSuggestionsPopup,
+ operator.FormatElementValueRow,
+ operator.ApplyElementValueRowsToLiteral,
+ operator.ShowCategoryHelp,
+ operator.ShowElementValuesInstructions,
operator.LoadDrawings,
operator.LoadReferences,
operator.LoadSchedules,
@@ -89,8 +100,10 @@ classes = (
operator.SelectAllDrawings,
operator.SelectAllSheets,
operator.SelectAssignedProduct,
+ operator.SelectSimilarTextLiteralValue,
operator.ToggleTargetView,
operator.OpenDocumentationWebUi,
+ operator.FilterSelectedObjectsIfIntersectedByCamera,
prop.Variable,
prop.Drawing,
prop.Document,
@@ -98,7 +111,9 @@ classes = (
prop.Sheet,
prop.DocProperties,
prop.BIMCameraProperties,
+ prop.ElementValueRow,
prop.LiteralProps,
+ prop.LiteralApplySettings,
prop.BIMTextProperties,
prop.BIMAssignedProductProperties,
prop.BIMAnnotationProperties,
@@ -143,7 +158,6 @@ def menu_func(self, context):
if element and element.is_a("IfcAnnotation") and element.ObjectType in ["SECTION", "ELEVATION"]:
self.layout.operator("bim.activate_drawing_by_annotation", text="Go to Drawing")
-
def register():
if not bpy.app.background:
bpy.utils.register_tool(workspace.AnnotationTool, after={"bim.bim_tool"}, separator=True, group=False)
@@ -156,7 +170,7 @@ def register():
bpy.app.handlers.load_post.append(handler.load_post)
bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler)
bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button)
- bpy.types.VIEW3D_MT_object_context_menu.append(menu_func)
+ bpy.types.VIEW3D_MT_object_context_menu.append(menu_func)
def unregister():
diff --git a/src/bonsai/bonsai/bim/module/drawing/data.py b/src/bonsai/bonsai/bim/module/drawing/data.py
index af8fac3cdf..1b3d1255a1 100644
--- a/src/bonsai/bonsai/bim/module/drawing/data.py
+++ b/src/bonsai/bonsai/bim/module/drawing/data.py
@@ -22,6 +22,7 @@ import json
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.unit
+import ifcopenshell.util.selector
import bonsai.tool as tool
from pathlib import Path
from typing import Any, Union
@@ -335,19 +336,17 @@ class DecoratorData:
@classmethod
def get_text_data(cls, obj: bpy.types.Object) -> dict[str, Any]:
- """used by Ifc Annotations with ObjectType = "TEXT" / "TEXT_LEADER"\n
+ """used by Ifc Annotations with ObjectType = "TEXT" / "TEXT_LEADER"
returns font size in mm for current ifc text object"""
element = tool.Ifc.get_entity(obj)
assert element
# getting font size
pset_data = ifcopenshell.util.element.get_pset(element, "EPset_Annotation") or {}
- # use `regular` as default
# get font size
classes = pset_data.get("Classes", None) or "regular"
classes_split = classes.split()
- # prioritize smaller font sizes just like in svg
font_size_type = next(
(font_size_type for font_size_type in FONT_SIZES if font_size_type in classes_split), "regular"
)
@@ -361,15 +360,22 @@ class DecoratorData:
literals = tool.Drawing.get_text_literal(obj, return_list=True)
assert isinstance(literals, list)
literals_data: list[dict[str, Any]] = []
- product = tool.Drawing.get_assigned_product(element) or element
+
+ product = cls.get_product_for_element_values(obj, element)
+
for literal in literals:
literal_value = literal.Literal
+
+ try:
+ current_value = cls.evaluate_formatting_expressions(literal_value)
+ current_value = tool.Drawing.replace_text_literal_variables(current_value, product)
+ except Exception:
+ current_value = literal_value
+
literal_data = {
"Literal": literal_value,
"BoxAlignment": literal.BoxAlignment,
- "CurrentValue": tool.Drawing.replace_text_literal_variables(
- literal_value, product, reverse_list, list_separator
- ),
+ "CurrentValue": current_value,
}
literals_data.append(literal_data)
@@ -382,6 +388,419 @@ class DecoratorData:
"List_Separator": list_separator,
}
+ @classmethod
+ def get_product_for_element_values(cls, text_obj: bpy.types.Object, element: ifcopenshell.entity_instance):
+ """Get the product to use for element values - either ProductUsed or assigned product"""
+ props = tool.Drawing.get_text_props(text_obj)
+ if props.literals:
+ for literal_props in props.literals:
+ if hasattr(literal_props, "product_used") and literal_props.product_used:
+ return tool.Ifc.get_entity(literal_props.product_used)
+
+ assigned_product = tool.Drawing.get_assigned_product(element)
+ if assigned_product:
+ return assigned_product
+
+ return element
+
+ @classmethod
+ def evaluate_formatting_expressions(cls, text: str) -> str:
+ """Evaluate formatting expressions wrapped in backticks using ifcopenshell.util.selector.format"""
+ import re
+
+ def evaluate_expression(match):
+ try:
+ expression = match.group(1)
+ result = ifcopenshell.util.selector.format(expression)
+ return str(result)
+ except Exception as e:
+ return match.group(0)
+
+ return re.sub(r"``([^`]+)``", evaluate_expression, text)
+
+ @classmethod
+ def get_element_value_by_key(cls, element: ifcopenshell.entity_instance, key: str):
+ """Get element value by its key using IfcOpenShell selector syntax"""
+ try:
+ # Basic keys
+ if key == "id":
+ return element.id()
+ elif key == "class":
+ return element.is_a()
+ elif key == "predefined_type":
+ return ifcopenshell.util.element.get_predefined_type(element)
+
+ # Direct attributes
+ elif hasattr(element, key):
+ return getattr(element, key)
+
+ # Material keys
+ elif key.startswith("material"):
+ return cls._get_material_value(element, key)
+
+ # Type keys
+ elif key.startswith("type."):
+ if hasattr(element, "IsTypedBy") and element.IsTypedBy:
+ element_type = element.IsTypedBy[0].RelatingType
+ attr_name = key.split(".", 1)[1]
+ if hasattr(element_type, attr_name):
+ return getattr(element_type, attr_name)
+
+ elif key == "types.count":
+ if hasattr(element, "IsTypedBy") and element.IsTypedBy:
+ element_type = element.IsTypedBy[0].RelatingType
+ occurrence_count = 0
+ if hasattr(element_type, "Types"):
+ for rel in element_type.Types:
+ if hasattr(rel, "RelatedObjects"):
+ occurrence_count += len(rel.RelatedObjects)
+ return occurrence_count
+
+ elif key == "occurrences.count":
+ if element.is_a("IfcTypeProduct"):
+ occurrence_count = 0
+ if hasattr(element, "Types"):
+ for rel in element.Types:
+ if hasattr(rel, "RelatedObjects"):
+ occurrence_count += len(rel.RelatedObjects)
+ return occurrence_count
+
+ # Spatial keys
+ elif key.startswith("container."):
+ container = ifcopenshell.util.element.get_container(element)
+ if container:
+ attr_name = key.split(".", 1)[1]
+ if hasattr(container, attr_name):
+ return getattr(container, attr_name)
+
+ elif key.startswith("space."):
+ container = ifcopenshell.util.element.get_container(element)
+ current = container
+ while current:
+ if current.is_a("IfcSpace"):
+ attr_name = key.split(".", 1)[1]
+ if hasattr(current, attr_name):
+ return getattr(current, attr_name)
+ break
+ current = ifcopenshell.util.element.get_aggregate(current)
+
+ elif key.startswith("storey."):
+ container = ifcopenshell.util.element.get_container(element)
+ current = container
+ while current:
+ if current.is_a("IfcBuildingStorey"):
+ attr_name = key.split(".", 1)[1]
+ if hasattr(current, attr_name):
+ return getattr(current, attr_name)
+ break
+ current = ifcopenshell.util.element.get_aggregate(current)
+
+ elif key.startswith("building."):
+ container = ifcopenshell.util.element.get_container(element)
+ current = container
+ while current:
+ if current.is_a("IfcBuilding"):
+ attr_name = key.split(".", 1)[1]
+ if hasattr(current, attr_name):
+ return getattr(current, attr_name)
+ break
+ current = ifcopenshell.util.element.get_aggregate(current)
+
+ elif key.startswith("site."):
+ container = ifcopenshell.util.element.get_container(element)
+ current = container
+ while current:
+ if current.is_a("IfcSite"):
+ attr_name = key.split(".", 1)[1]
+ if hasattr(current, attr_name):
+ return getattr(current, attr_name)
+ break
+ current = ifcopenshell.util.element.get_aggregate(current)
+
+ # Parent keys
+ elif key.startswith("parent."):
+ parent = ifcopenshell.util.element.get_aggregate(element)
+ if parent:
+ attr_name = key.split(".", 1)[1]
+ if hasattr(parent, attr_name):
+ return getattr(parent, attr_name)
+
+ # Group, system, zone keys
+ elif key.startswith(("group.", "system.", "zone.")):
+ prefix = key.split(".")[0]
+ attr_name = key.split(".", 1)[1]
+
+ if hasattr(element, "HasAssignments"):
+ for assignment in element.HasAssignments:
+ if assignment.is_a("IfcRelAssignsToGroup"):
+ group = assignment.RelatingGroup
+ if (
+ prefix == "group"
+ and group.is_a("IfcGroup")
+ and not group.is_a("IfcSystem")
+ and not group.is_a("IfcZone")
+ ):
+ if hasattr(group, attr_name):
+ return getattr(group, attr_name)
+ elif prefix == "system" and group.is_a("IfcSystem"):
+ if hasattr(group, attr_name):
+ return getattr(group, attr_name)
+ elif prefix == "zone" and group.is_a("IfcZone"):
+ if hasattr(group, attr_name):
+ return getattr(group, attr_name)
+
+ elif key in ("groups.count", "systems.count", "zones.count"):
+ prefix = key.split(".")[0]
+ count = 0
+
+ if hasattr(element, "HasAssignments"):
+ for assignment in element.HasAssignments:
+ if assignment.is_a("IfcRelAssignsToGroup"):
+ group = assignment.RelatingGroup
+ if (
+ prefix == "groups"
+ and group.is_a("IfcGroup")
+ and not group.is_a("IfcSystem")
+ and not group.is_a("IfcZone")
+ ):
+ count += 1
+ elif prefix == "systems" and group.is_a("IfcSystem"):
+ count += 1
+ elif prefix == "zones" and group.is_a("IfcZone"):
+ count += 1
+
+ return count
+
+ # Classification keys
+ elif key.startswith("classification."):
+ import re
+
+ match = re.match(r"classification\.(\d+)\.(\w+)", key)
+ if match:
+ idx = int(match.group(1))
+ attr_name = match.group(2)
+ classifications = ifcopenshell.util.classification.get_references(element)
+ if classifications and 0 <= idx < len(classifications):
+ classification = classifications[idx]
+ if hasattr(classification, attr_name):
+ return getattr(classification, attr_name)
+
+ elif key == "classification.count":
+ classifications = ifcopenshell.util.classification.get_references(element)
+ return len(classifications) if classifications else 0
+
+ # Profile keys
+ elif key.startswith("profiles."):
+ import re
+
+ if key == "profiles.count":
+ material = ifcopenshell.util.element.get_material(element, should_skip_usage=True)
+ if material and material.is_a("IfcMaterialProfileSet") and hasattr(material, "MaterialProfiles"):
+ return len(material.MaterialProfiles)
+ return 0
+
+ match = re.match(r"profiles\.(\d+)\.(\w+)", key)
+ if match:
+ idx = int(match.group(1))
+ attr_name = match.group(2)
+ material = ifcopenshell.util.element.get_material(element, should_skip_usage=True)
+ if material and material.is_a("IfcMaterialProfileSet") and hasattr(material, "MaterialProfiles"):
+ if 0 <= idx < len(material.MaterialProfiles):
+ profile_item = material.MaterialProfiles[idx]
+ if hasattr(profile_item, "Profile") and profile_item.Profile:
+ profile = profile_item.Profile
+ if hasattr(profile, attr_name):
+ return getattr(profile, attr_name)
+ return None
+
+ elif key.startswith("profile."):
+ attr_name = key.split(".", 1)[1]
+ if hasattr(element, "Representation") and element.Representation:
+ for representation in element.Representation.Representations:
+ if hasattr(representation, "Items"):
+ for item in representation.Items:
+ if item.is_a("IfcExtrudedAreaSolid") and hasattr(item, "SweptArea"):
+ profile = item.SweptArea
+ if hasattr(profile, attr_name):
+ return getattr(profile, attr_name)
+ return None
+
+ # Style keys
+ elif key.startswith("styles."):
+ import re
+
+ if key == "styles.count":
+ try:
+ styles = ifcopenshell.util.element.get_styles(element)
+ return len(styles) if styles else 0
+ except:
+ return 0
+
+ match = re.match(r"styles\.(\d+)\.(\w+)", key)
+ if match:
+ idx = int(match.group(1))
+ attr_name = match.group(2)
+ try:
+ styles = ifcopenshell.util.element.get_styles(element)
+ if styles and 0 <= idx < len(styles):
+ style = styles[idx]
+ if attr_name == "Color" and style.is_a("IfcSurfaceStyle"):
+ # Extract color from surface style
+ if hasattr(style, "Styles"):
+ for surface_style_elem in style.Styles:
+ if surface_style_elem.is_a("IfcSurfaceStyleRendering"):
+ if hasattr(surface_style_elem, "SurfaceColour"):
+ color = surface_style_elem.SurfaceColour
+ return f"RGB({color.Red:.2f}, {color.Green:.2f}, {color.Blue:.2f})"
+ elif hasattr(style, attr_name):
+ return getattr(style, attr_name)
+ except:
+ pass
+ return None
+
+ # Coordinate keys
+ elif key in ("x", "y", "z"):
+ if hasattr(element, "ObjectPlacement") and element.ObjectPlacement:
+ matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
+ if matrix is not None:
+ if key == "x":
+ return matrix[0][3]
+ elif key == "y":
+ return matrix[1][3]
+ elif key == "z":
+ return matrix[2][3]
+ return None
+
+ elif key in ("easting", "northing", "elevation"):
+ if hasattr(element, "ObjectPlacement") and element.ObjectPlacement:
+ try:
+ matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
+ if matrix is not None:
+ ifc_file = element.wrapped_data.file
+ project = ifc_file.by_type("IfcProject")[0] if ifc_file.by_type("IfcProject") else None
+
+ if project:
+ for context in project.RepresentationContexts or []:
+ if context.is_a("IfcGeometricRepresentationContext") and hasattr(
+ context, "HasCoordinateOperation"
+ ):
+ for coord_op in context.HasCoordinateOperation:
+ if coord_op.is_a("IfcMapConversion"):
+ if key == "easting":
+ return matrix[0][3] + coord_op.Eastings
+ elif key == "northing":
+ return matrix[1][3] + coord_op.Northings
+ elif key == "elevation":
+ return matrix[2][3] + coord_op.OrthogonalHeight
+ except:
+ pass
+ return None
+
+ # Property sets / Quantity sets (must be checked last to avoid conflicts)
+ elif "." in key:
+ parts = key.split(".", 1)
+ if len(parts) == 2:
+ pset_name, prop_name = parts
+ psets = ifcopenshell.util.element.get_psets(element, psets_only=True)
+ qsets = ifcopenshell.util.element.get_psets(element, qtos_only=True)
+ all_psets = {**psets, **qsets}
+
+ if pset_name in all_psets and prop_name in all_psets[pset_name]:
+ return all_psets[pset_name][prop_name]
+
+ # Check for regex patterns (e.g., /Pset_.*Common/)
+ import re
+
+ if pset_name.startswith("/") and pset_name.endswith("/"):
+ pattern = pset_name[1:-1]
+ try:
+ regex = re.compile(pattern)
+ for actual_pset_name in all_psets.keys():
+ if regex.match(actual_pset_name):
+ if prop_name in all_psets[actual_pset_name]:
+ return all_psets[actual_pset_name][prop_name]
+ elif prop_name.startswith("/") and prop_name.endswith("/"):
+ prop_pattern = prop_name[1:-1]
+ prop_regex = re.compile(prop_pattern)
+ for actual_prop_name, prop_value in all_psets[actual_pset_name].items():
+ if prop_regex.match(actual_prop_name):
+ return prop_value
+ except re.error:
+ pass
+
+ except Exception as e:
+ print(f"Error getting value for key '{key}': {e}")
+
+ return None
+
+ @classmethod
+ def _get_material_value(cls, element: ifcopenshell.entity_instance, key: str):
+ """Extract material values using correct selector syntax"""
+ material = ifcopenshell.util.element.get_material(element, should_skip_usage=True)
+ if not material:
+ return None
+
+ if key == "materials.count":
+ if material.is_a("IfcMaterialLayerSetUsage") and material.ForLayerSet:
+ layer_set = material.ForLayerSet
+ if hasattr(layer_set, "MaterialLayers"):
+ return len(layer_set.MaterialLayers)
+ elif material.is_a("IfcMaterialLayerSet") and hasattr(material, "MaterialLayers"):
+ return len(material.MaterialLayers)
+ elif material.is_a("IfcMaterialProfileSet") and hasattr(material, "MaterialProfiles"):
+ return len(material.MaterialProfiles)
+ elif material.is_a("IfcMaterialConstituentSet") and hasattr(material, "MaterialConstituents"):
+ return len(material.MaterialConstituents)
+ elif material.is_a("IfcMaterial"):
+ return 1
+ return 0
+
+ if key == "material.Name":
+ return getattr(material, "Name", None)
+
+ elif key.startswith("material.item."):
+ import re
+
+ match = re.match(r"material\.item\.(\d+)\.(\w+)", key)
+ if match:
+ item_idx = int(match.group(1))
+ prop_name = match.group(2)
+
+ items = cls._get_material_items(material)
+ if items and 0 <= item_idx < len(items):
+ item = items[item_idx]
+ if prop_name == "Name" and hasattr(item, "Material") and item.Material:
+ return getattr(item.Material, "Name", None)
+ elif prop_name == "LayerThickness" and hasattr(item, "LayerThickness"):
+ return getattr(item, "LayerThickness", None)
+ elif hasattr(item, prop_name):
+ return getattr(item, prop_name, None)
+
+ match = re.match(r"material\.item\.Material\.Name\.(\d+)", key)
+ if match:
+ item_idx = int(match.group(1))
+ items = cls._get_material_items(material)
+ if items and 0 <= item_idx < len(items):
+ item = items[item_idx]
+ if hasattr(item, "Material") and item.Material:
+ return getattr(item.Material, "Name", None)
+
+ return None
+
+ @classmethod
+ def _get_material_items(cls, material):
+ """Get material items (layers, profiles, or constituents) from material"""
+ if material.is_a("IfcMaterialLayerSetUsage") and hasattr(material, "ForLayerSet") and material.ForLayerSet:
+ if hasattr(material.ForLayerSet, "MaterialLayers"):
+ return material.ForLayerSet.MaterialLayers
+ elif material.is_a("IfcMaterialLayerSet") and hasattr(material, "MaterialLayers"):
+ return material.MaterialLayers
+ elif material.is_a("IfcMaterialProfileSet") and hasattr(material, "MaterialProfiles"):
+ return material.MaterialProfiles
+ elif material.is_a("IfcMaterialConstituentSet") and hasattr(material, "MaterialConstituents"):
+ return material.MaterialConstituents
+ return None
+
@classmethod
def get_dimension_data(cls, obj: bpy.types.Object) -> dict[str, Any]:
"""used by Ifc Annotations with ObjectType:
@@ -514,3 +933,390 @@ class AnnotationData:
)
return sorted(relating_types, key=lambda x: x["name"])
+
+
+class ElementValuesData:
+ data = {}
+ is_loaded = False
+
+ @classmethod
+ def load(cls):
+ cls.is_loaded = True
+
+ @classmethod
+ def get_flattened_keys(cls, available_keys: dict[str, list[tuple[str, str]]]) -> list[tuple[str, str]]:
+ """Flatten all keys from all categories into a single list for numbering"""
+ all_keys_flat = []
+ for cat_name, cat_keys in available_keys.items():
+ for cat_key, cat_desc in cat_keys:
+ all_keys_flat.append((cat_key, cat_desc))
+ return all_keys_flat
+
+ @classmethod
+ def get_available_element_value_keys(
+ cls, element: ifcopenshell.entity_instance
+ ) -> dict[str, list[tuple[str, str]]]:
+ """Get all available selector syntax keys for the element"""
+ keys = {}
+
+ keys["Basic"] = cls._get_basic_keys(element)
+
+ keys["Attributes"] = cls._get_attribute_keys(element)
+
+ keys["Property Sets"] = cls._get_pset_keys(element)
+ keys["Quantity Sets"] = cls._get_qset_keys(element)
+
+ keys["Type"] = cls._get_type_keys(element)
+ keys["Spatial"] = cls._get_spatial_keys(element)
+ keys["Parent"] = cls._get_parent_keys(element)
+ keys["Groups"] = cls._get_group_keys(element)
+ keys["Systems"] = cls._get_system_keys(element)
+ keys["Zones"] = cls._get_zone_keys(element)
+
+ keys["Material"] = cls._get_material_keys(element)
+ keys["Styles"] = cls._get_style_keys(element)
+
+ keys["Classification"] = cls._get_classification_keys(element)
+
+ keys["Profiles"] = cls._get_profile_keys(element)
+
+ keys["Coordinates"] = cls._get_coordinate_keys()
+
+ return keys
+
+ @classmethod
+ def _get_basic_keys(cls, element):
+ keys = []
+ keys.append(("id", f"IFC ID: {element.id()}"))
+ keys.append(("class", f"IFC Class: {element.is_a()}"))
+
+ predefined_type = ifcopenshell.util.element.get_predefined_type(element)
+ if predefined_type and predefined_type != "NOTDEFINED":
+ keys.append(("predefined_type", f"Predefined Type: {predefined_type}"))
+
+ return keys
+
+ @classmethod
+ def _get_attribute_keys(cls, element):
+ keys = []
+ excluded_attrs = {"id", "type", "GlobalId", "OwnerHistory", "ObjectPlacement", "Representation"}
+
+ for attr_name in element.get_info().keys():
+ if attr_name not in excluded_attrs:
+ attr_value = getattr(element, attr_name, None)
+ if attr_value is not None:
+ keys.append((attr_name, f"{attr_name}: {attr_value}"))
+
+ return keys
+
+ @classmethod
+ def _get_pset_keys(cls, element):
+ keys = []
+ psets = ifcopenshell.util.element.get_psets(element, psets_only=True)
+ if psets:
+ for pset_name, props in psets.items():
+ for prop_name, prop_value in props.items():
+ if isinstance(prop_value, (str, int, float, bool)):
+ keys.append((f"{pset_name}.{prop_name}", f"{pset_name}.{prop_name}: {prop_value}"))
+ return keys
+
+ @classmethod
+ def _get_qset_keys(cls, element):
+ keys = []
+ qsets = ifcopenshell.util.element.get_psets(element, qtos_only=True)
+ if qsets:
+ for qset_name, quantities in qsets.items():
+ for qty_name, qty_value in quantities.items():
+ if isinstance(qty_value, (str, int, float)):
+ keys.append((f"{qset_name}.{qty_name}", f"{qset_name}.{qty_name}: {qty_value}"))
+ return keys
+
+ @classmethod
+ def _get_material_keys(cls, element):
+ keys = []
+ material = ifcopenshell.util.element.get_material(element, should_skip_usage=True)
+ if not material:
+ return keys
+
+ if hasattr(material, "Name") and material.Name:
+ keys.append(("material.Name", f"Material: {material.Name}"))
+
+ material_count = 0
+ if material.is_a("IfcMaterialLayerSetUsage") and material.ForLayerSet:
+ layer_set = material.ForLayerSet
+ if hasattr(layer_set, "MaterialLayers"):
+ material_count = len(layer_set.MaterialLayers)
+ for i, layer in enumerate(layer_set.MaterialLayers):
+ if layer.Material and layer.Material.Name:
+ keys.append(
+ (f"material.item.Material.Name.{i}", f"Layer {i+1} Material Name: {layer.Material.Name}")
+ )
+ if hasattr(layer, "LayerThickness") and layer.LayerThickness:
+ keys.append(
+ (f"material.item.{i}.LayerThickness", f"Layer {i+1} Thickness: {layer.LayerThickness}")
+ )
+
+ elif material.is_a("IfcMaterialLayerSet") and hasattr(material, "MaterialLayers"):
+ material_count = len(material.MaterialLayers)
+ for i, layer in enumerate(material.MaterialLayers):
+ if layer.Material and layer.Material.Name:
+ keys.append(
+ (f"material.item.Material.Name.{i}", f"Layer {i+1} Material Name: {layer.Material.Name}")
+ )
+ if hasattr(layer, "LayerThickness") and layer.LayerThickness:
+ keys.append((f"material.item.{i}.LayerThickness", f"Layer {i+1} Thickness: {layer.LayerThickness}"))
+
+ elif material.is_a("IfcMaterialProfileSet") and hasattr(material, "MaterialProfiles"):
+ material_count = len(material.MaterialProfiles)
+ for i, profile in enumerate(material.MaterialProfiles):
+ if profile.Material and profile.Material.Name:
+ keys.append(
+ (f"material.item.Material.Name.{i}", f"Profile {i+1} Material Name: {profile.Material.Name}")
+ )
+
+ elif material.is_a("IfcMaterialConstituentSet") and hasattr(material, "MaterialConstituents"):
+ material_count = len(material.MaterialConstituents)
+ for i, constituent in enumerate(material.MaterialConstituents):
+ if constituent.Material and constituent.Material.Name:
+ keys.append(
+ (
+ f"material.item.Material.Name.{i}",
+ f"Constituent {i+1} Material Name: {constituent.Material.Name}",
+ )
+ )
+
+ elif material.is_a("IfcMaterial"):
+ material_count = 1
+
+ if material_count > 0:
+ keys.append(("materials.count", f"Material Count: {material_count}"))
+
+ return keys
+
+ @classmethod
+ def _get_style_keys(cls, element):
+ """Get presentation styles from the element"""
+ keys = []
+
+ styles = ifcopenshell.util.element.get_styles(element)
+ if styles:
+ keys.append(("styles.count", f"Style Count: {len(styles)}"))
+ for i, style in enumerate(styles):
+ if hasattr(style, "Name") and style.Name:
+ keys.append((f"styles.{i}.Name", f"Style {i+1} Name: {style.Name}"))
+ if style.is_a("IfcSurfaceStyle") and hasattr(style, "Styles"):
+ for surface_style_elem in style.Styles:
+ if surface_style_elem.is_a("IfcSurfaceStyleRendering"):
+ if hasattr(surface_style_elem, "SurfaceColour"):
+ color = surface_style_elem.SurfaceColour
+ if hasattr(color, "Red") and hasattr(color, "Green") and hasattr(color, "Blue"):
+ rgb = f"RGB({color.Red:.2f}, {color.Green:.2f}, {color.Blue:.2f})"
+ keys.append((f"styles.{i}.Color", f"Style {i+1} Color: {rgb}"))
+
+ return keys
+
+ @classmethod
+ def _get_type_keys(cls, element):
+ keys = []
+
+ if hasattr(element, "IsTypedBy") and element.IsTypedBy:
+ element_type = element.IsTypedBy[0].RelatingType
+ if hasattr(element_type, "Name") and element_type.Name:
+ keys.append(("type.Name", f"Type Name: {element_type.Name}"))
+
+ if element.is_a("IfcTypeProduct"):
+ occurrence_count = 0
+ if hasattr(element, "Types"):
+ for rel in element.Types:
+ if hasattr(rel, "RelatedObjects"):
+ occurrence_count += len(rel.RelatedObjects)
+ keys.append(("occurrences.count", f"Occurrence Count: {occurrence_count}"))
+
+ elif hasattr(element, "IsTypedBy") and element.IsTypedBy:
+ element_type = element.IsTypedBy[0].RelatingType
+ occurrence_count = 0
+ if hasattr(element_type, "Types"):
+ for rel in element_type.Types:
+ if hasattr(rel, "RelatedObjects"):
+ occurrence_count += len(rel.RelatedObjects)
+ keys.append(("types.count", f"Type Occurrence Count: {occurrence_count}"))
+
+ return keys
+
+ @classmethod
+ def _get_spatial_keys(cls, element):
+ keys = []
+
+ container = ifcopenshell.util.element.get_container(element)
+ if container and hasattr(container, "Name") and container.Name:
+ keys.append(("container.Name", f"Container: {container.Name}"))
+
+ space = None
+ current = container
+ while current:
+ if current.is_a("IfcSpace"):
+ space = current
+ break
+ current = ifcopenshell.util.element.get_aggregate(current)
+ if space and hasattr(space, "Name") and space.Name:
+ keys.append(("space.Name", f"Space: {space.Name}"))
+
+ storey = None
+ current = container
+ while current:
+ if current.is_a("IfcBuildingStorey"):
+ storey = current
+ break
+ current = ifcopenshell.util.element.get_aggregate(current)
+ if storey and hasattr(storey, "Name") and storey.Name:
+ keys.append(("storey.Name", f"Storey: {storey.Name}"))
+
+ building = None
+ current = container
+ while current:
+ if current.is_a("IfcBuilding"):
+ building = current
+ break
+ current = ifcopenshell.util.element.get_aggregate(current)
+ if building and hasattr(building, "Name") and building.Name:
+ keys.append(("building.Name", f"Building: {building.Name}"))
+
+ site = None
+ current = container
+ while current:
+ if current.is_a("IfcSite"):
+ site = current
+ break
+ current = ifcopenshell.util.element.get_aggregate(current)
+ if site and hasattr(site, "Name") and site.Name:
+ keys.append(("site.Name", f"Site: {site.Name}"))
+
+ return keys
+
+ @classmethod
+ def _get_parent_keys(cls, element):
+ keys = []
+ parent = ifcopenshell.util.element.get_aggregate(element)
+ if parent and hasattr(parent, "Name") and parent.Name:
+ keys.append((f"parent.name", f"Parent: {parent.Name}"))
+ return keys
+
+ @classmethod
+ def _get_group_keys(cls, element):
+ keys = []
+ groups = []
+ if hasattr(element, "HasAssignments"):
+ for assignment in element.HasAssignments:
+ if assignment.is_a("IfcRelAssignsToGroup"):
+ group = assignment.RelatingGroup
+ if group.is_a("IfcGroup") and not group.is_a("IfcSystem") and not group.is_a("IfcZone"):
+ groups.append(group)
+ if group.Name:
+ keys.append((f"group.Name", f"Group: {group.Name}"))
+
+ if groups:
+ keys.append(("groups.count", f"Group Count: {len(groups)}"))
+
+ return keys
+
+ @classmethod
+ def _get_system_keys(cls, element):
+ keys = []
+ systems = []
+ if hasattr(element, "HasAssignments"):
+ for assignment in element.HasAssignments:
+ if assignment.is_a("IfcRelAssignsToGroup"):
+ group = assignment.RelatingGroup
+ if group.is_a("IfcSystem"):
+ systems.append(group)
+ if group.Name:
+ keys.append((f"system.Name", f"System: {group.Name}"))
+
+ if systems:
+ keys.append(("systems.count", f"System Count: {len(systems)}"))
+
+ return keys
+
+ @classmethod
+ def _get_zone_keys(cls, element):
+ keys = []
+ zones = []
+ if hasattr(element, "HasAssignments"):
+ for assignment in element.HasAssignments:
+ if assignment.is_a("IfcRelAssignsToGroup"):
+ group = assignment.RelatingGroup
+ if group.is_a("IfcZone"):
+ zones.append(group)
+ if group.Name:
+ keys.append((f"zone.Name", f"Zone: {group.Name}"))
+
+ if zones:
+ keys.append(("zones.count", f"Zone Count: {len(zones)}"))
+
+ return keys
+
+ @classmethod
+ def _get_classification_keys(cls, element):
+ keys = []
+ classifications = ifcopenshell.util.classification.get_references(element)
+ if classifications:
+ for i, classification in enumerate(classifications):
+ if hasattr(classification, "Name") and classification.Name:
+ keys.append((f"classification.{i}.Name", f"Classification {i+1}: {classification.Name}"))
+ if hasattr(classification, "Identification") and classification.Identification:
+ keys.append(
+ (
+ f"classification.{i}.Identification",
+ f"Classification {i+1} ID: {classification.Identification}",
+ )
+ )
+
+ keys.append(("classification.count", f"Classification Count: {len(classifications)}"))
+
+ return keys
+
+ @classmethod
+ def _get_coordinate_keys(cls):
+ return [
+ ("x", "X Coordinate"),
+ ("y", "Y Coordinate"),
+ ("z", "Z Coordinate"),
+ ("easting", "Easting"),
+ ("northing", "Northing"),
+ ("elevation", "Elevation"),
+ ]
+
+ @classmethod
+ def _get_profile_keys(cls, element):
+ """Get profile definitions from the element"""
+ keys = []
+
+ material = ifcopenshell.util.element.get_material(element, should_skip_usage=True)
+ if material:
+ profiles = []
+ if material.is_a("IfcMaterialProfileSet") and hasattr(material, "MaterialProfiles"):
+ for profile in material.MaterialProfiles:
+ if hasattr(profile, "Profile") and profile.Profile:
+ profiles.append(profile.Profile)
+
+ if profiles:
+ keys.append(("profiles.count", f"Profile Count: {len(profiles)}"))
+ for i, profile in enumerate(profiles):
+ if hasattr(profile, "ProfileName") and profile.ProfileName:
+ keys.append((f"profiles.{i}.ProfileName", f"Profile {i+1} Name: {profile.ProfileName}"))
+ if hasattr(profile, "ProfileType") and profile.ProfileType:
+ keys.append((f"profiles.{i}.ProfileType", f"Profile {i+1} Type: {profile.ProfileType}"))
+
+ if hasattr(element, "Representation") and element.Representation:
+ for representation in element.Representation.Representations:
+ if hasattr(representation, "Items"):
+ for item in representation.Items:
+ if item.is_a("IfcExtrudedAreaSolid") and hasattr(item, "SweptArea"):
+ profile = item.SweptArea
+ if hasattr(profile, "ProfileName") and profile.ProfileName:
+ keys.append(("profile.ProfileName", f"Swept Profile Name: {profile.ProfileName}"))
+ if hasattr(profile, "ProfileType") and profile.ProfileType:
+ keys.append(("profile.ProfileType", f"Swept Profile Type: {profile.ProfileType}"))
+ break
+
+ return keys
diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py
index e874983074..66c417f3ec 100644
--- a/src/bonsai/bonsai/bim/module/drawing/operator.py
+++ b/src/bonsai/bonsai/bim/module/drawing/operator.py
@@ -19,6 +19,7 @@
import os
import bpy
import json
+import re
import time
import bmesh
import shutil
@@ -50,7 +51,9 @@ import bonsai.bim.module.drawing.sheeter as sheeter
import bonsai.bim.export_ifc
from bpy_extras.io_utils import ImportHelper
from bonsai.bim.module.drawing.decoration import CutDecorator
-from bonsai.bim.module.drawing.data import DecoratorData
+from bonsai.bim.module.drawing.data import DecoratorData, ElementValuesData
+from bonsai.bim.module.drawing.ui import get_current_product_for_element_values
+from bonsai.bim.prop import StrProperty
from typing import NamedTuple, Union, Optional, Literal, TYPE_CHECKING, Any, TypedDict, get_args
from lxml import etree
from math import radians
@@ -305,7 +308,6 @@ class CreateDrawing(bpy.types.Operator):
# Process events to let Blender finish internal cleanup
bpy.ops.wm.redraw_timer(type="DRAW_WIN_SWAP", iterations=1)
-
self.camera = context.scene.camera
assert (camera_element := tool.Ifc.get_entity(self.camera))
self.camera_element = camera_element
@@ -351,20 +353,20 @@ class CreateDrawing(bpy.types.Operator):
# Clear any local camera setup and force viewport to use scene camera
for area in context.screen.areas:
- if area.type == "VIEW_3D":
+ if area.type == 'VIEW_3D':
for space in area.spaces:
- if space.type == "VIEW_3D":
+ if space.type == 'VIEW_3D':
# Clear local camera to ensure we use scene.camera
space.use_local_camera = False
space.camera = context.scene.camera
- space.region_3d.view_perspective = "CAMERA"
+ space.region_3d.view_perspective = 'CAMERA'
print(f"Set viewport camera to: {context.scene.camera.name}")
break
-
+
# Force complete scene update
context.view_layer.update()
context.evaluated_depsgraph_get()
-
+
underlay_svg = self.generate_underlay(context)
with profile("Generate linework"):
@@ -3076,9 +3078,9 @@ class AddReference(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
filter_glob: bpy.props.StringProperty(default="*.svg", options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True)
filename_ext = ".svg"
-
+
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement)
- directory: bpy.props.StringProperty(subtype="DIR_PATH")
+ directory: bpy.props.StringProperty(subtype='DIR_PATH')
def _execute(self, context):
# Handle both single and multiple file selection
@@ -3168,9 +3170,166 @@ class EditText(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
- core.edit_text(tool.Drawing, obj=context.active_object)
+ obj = context.active_object
+ props = tool.Drawing.get_text_props(obj)
+
+ captured_apply_settings = {
+ "apply_font_size_to_all": props.apply_font_size_to_all,
+ "apply_newline_to_all": props.apply_newline_to_all,
+ "font_size": props.font_size,
+ "newline_at": props.newline_at,
+ "literals": [],
+ }
+
+ for i, literal in enumerate(props.literals):
+ literal_data = {
+ "attributes": [
+ (attr.string_value, attr.enum_value if attr.data_type == "enum" else attr.string_value)
+ for attr in literal.attributes
+ ],
+ "box_alignment": literal.box_alignment[:] if hasattr(literal, "box_alignment") else None,
+ "element_value_rows": [
+ {
+ "category": row.category,
+ "element_key": row.element_key,
+ "formatted_value": row.formatted_value,
+ "separator": row.separator,
+ }
+ for row in literal.element_value_rows
+ ],
+ "product_used": literal.product_used.name if literal.product_used else None,
+ }
+
+ if i < len(props.literal_apply_settings):
+ apply_settings = props.literal_apply_settings[i]
+ literal_data["apply_text_to_all"] = apply_settings.apply_text_to_all
+ literal_data["apply_path_to_all"] = apply_settings.apply_path_to_all
+ literal_data["apply_box_alignment_to_all"] = apply_settings.apply_box_alignment_to_all
+ else:
+ literal_data["apply_text_to_all"] = False
+ literal_data["apply_path_to_all"] = False
+ literal_data["apply_box_alignment_to_all"] = False
+
+ captured_apply_settings["literals"].append(literal_data)
+
+ obj["_bonsai_element_value_rows_backup"] = json.dumps(captured_apply_settings["literals"])
+
+ core.edit_text(tool.Drawing, obj=obj)
+
+ self.apply_to_selected_objects_with_captured_data(context, obj, captured_apply_settings)
+
tool.Blender.update_viewport()
+ return {"FINISHED"}
+
+ def apply_to_selected_objects(self, context, active_obj, active_props):
+ """Apply changes to other selected text objects based on toggle settings"""
+ selected_objects = [obj for obj in context.selected_objects if obj != active_obj]
+
+ for obj in selected_objects:
+ element = tool.Ifc.get_entity(obj)
+ if not element or not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]):
+ continue
+
+ obj_props = tool.Drawing.get_text_props(obj)
+ needs_update = False
+
+ if active_props.apply_font_size_to_all:
+ obj_props.font_size = active_props.font_size
+ needs_update = True
+
+ if active_props.apply_newline_to_all:
+ obj_props.newline_at = active_props.newline_at
+ needs_update = True
+
+ for i, active_literal in enumerate(active_props.literals):
+ if i >= len(obj_props.literals):
+ continue
+
+ obj_props.ensure_literal_apply_settings(len(obj_props.literals))
+ obj_literal = obj_props.literals[i]
+
+ if i < len(active_props.literal_apply_settings):
+ active_settings = active_props.literal_apply_settings[i]
+
+ if active_settings.apply_text_to_all:
+ if len(active_literal.attributes) > 0 and len(obj_literal.attributes) > 0:
+ obj_literal.attributes[0].string_value = active_literal.attributes[0].string_value
+ needs_update = True
+
+ if active_settings.apply_path_to_all:
+ if len(active_literal.attributes) > 1 and len(obj_literal.attributes) > 1:
+ if (
+ active_literal.attributes[1].data_type == "enum"
+ and obj_literal.attributes[1].data_type == "enum"
+ ):
+ obj_literal.attributes[1].enum_value = active_literal.attributes[1].enum_value
+ else:
+ obj_literal.attributes[1].string_value = active_literal.attributes[1].string_value
+ needs_update = True
+
+ if active_settings.apply_box_alignment_to_all:
+ obj_literal.box_alignment = active_literal.box_alignment[:]
+ needs_update = True
+
+ if needs_update:
+ core.edit_text(tool.Drawing, obj=obj)
+
+ def apply_to_selected_objects_with_captured_data(self, context, active_obj, captured_data):
+ """Apply changes to other selected text objects using captured apply settings"""
+ selected_objects = [obj for obj in context.selected_objects if obj != active_obj]
+
+ for obj in selected_objects:
+ element = tool.Ifc.get_entity(obj)
+ if not element:
+ continue
+ if not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]):
+ continue
+
+ obj_props = tool.Drawing.get_text_props(obj)
+
+ if len(obj_props.literals) == 0:
+ core.enable_editing_text(tool.Drawing, obj=obj)
+ obj_props.ensure_literal_apply_settings(len(obj_props.literals))
+
+ needs_update = False
+
+ if captured_data["apply_font_size_to_all"]:
+ obj_props.font_size = captured_data["font_size"]
+ needs_update = True
+
+ if captured_data["apply_newline_to_all"]:
+ obj_props.newline_at = captured_data["newline_at"]
+ needs_update = True
+
+ for i, captured_literal in enumerate(captured_data["literals"]):
+ if i >= len(obj_props.literals):
+ continue
+
+ obj_literal = obj_props.literals[i]
+
+ if captured_literal["apply_text_to_all"]:
+ if len(captured_literal["attributes"]) > 0 and len(obj_literal.attributes) > 0:
+ new_value = captured_literal["attributes"][0][0] # [0] = string_value
+ obj_literal.attributes[0].string_value = new_value
+ needs_update = True
+
+ if captured_literal["apply_path_to_all"]:
+ if len(captured_literal["attributes"]) > 1 and len(obj_literal.attributes) > 1:
+ new_value = captured_literal["attributes"][1][1] # [1] = enum_value or string_value
+ if obj_literal.attributes[1].data_type == "enum":
+ obj_literal.attributes[1].enum_value = new_value
+ else:
+ obj_literal.attributes[1].string_value = new_value
+ needs_update = True
+
+ if captured_literal["apply_box_alignment_to_all"] and captured_literal["box_alignment"]:
+ obj_literal.box_alignment = captured_literal["box_alignment"]
+ needs_update = True
+
+ if needs_update:
+ core.edit_text(tool.Drawing, obj=obj)
+
class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.enable_editing_text"
@@ -3180,7 +3339,46 @@ class EnableEditingText(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
def _execute(self, context):
- core.enable_editing_text(tool.Drawing, obj=context.active_object)
+ obj = context.active_object
+ props = tool.Drawing.get_text_props(obj)
+ core.enable_editing_text(tool.Drawing, obj=obj)
+
+ props.ensure_literal_apply_settings(len(props.literals))
+
+ text_element = tool.Ifc.get_entity(obj)
+ assigned_product_entity = tool.Drawing.get_assigned_product(text_element) if text_element else None
+ assigned_product_obj = tool.Ifc.get_object(assigned_product_entity) if assigned_product_entity else None
+
+ if "_bonsai_element_value_rows_backup" in obj:
+ try:
+ literals_backup = json.loads(obj["_bonsai_element_value_rows_backup"])
+ for i, literal_backup in enumerate(literals_backup):
+ if i < len(props.literals):
+ literal_props = props.literals[i]
+
+ if assigned_product_obj:
+ literal_props.product_used = assigned_product_obj
+ elif "product_used" in literal_backup and literal_backup["product_used"]:
+ product_name = literal_backup["product_used"]
+ if product_name in bpy.data.objects:
+ literal_props.product_used = bpy.data.objects[product_name]
+
+ literal_props.element_value_rows.clear()
+ if "element_value_rows" in literal_backup:
+ for row_data in literal_backup["element_value_rows"]:
+ new_row = literal_props.element_value_rows.add()
+ new_row.category = row_data.get("category", "")
+ new_row.element_key = row_data.get("element_key", "")
+ new_row.formatted_value = row_data.get("formatted_value", "")
+ new_row.separator = row_data.get("separator", "")
+ except (json.JSONDecodeError, KeyError) as e:
+ print(f"Failed to restore element_value_rows: {e}")
+ else:
+ if assigned_product_obj:
+ for literal_props in props.literals:
+ literal_props.product_used = assigned_product_obj
+
+ return {"FINISHED"}
class DisableEditingText(bpy.types.Operator, tool.Ifc.Operator):
@@ -3235,6 +3433,9 @@ class AddTextLiteral(bpy.types.Operator):
box_alignment_mask = [False] * 9
box_alignment_mask[6] = True # bottom_left box_alignment
literal_props.box_alignment = box_alignment_mask
+
+ props.ensure_literal_apply_settings(len(props.literals))
+
return {"FINISHED"}
@@ -3253,6 +3454,9 @@ class RemoveTextLiteral(bpy.types.Operator):
props = tool.Drawing.get_text_props(obj)
props.literals.remove(self.literal_prop_id)
tool.Blender.update_viewport()
+
+ props.ensure_literal_apply_settings(len(props.literals))
+
return {"FINISHED"}
@@ -3687,12 +3891,8 @@ class EnableEditingElementFilter(bpy.types.Operator, tool.Ifc.Operator):
if query := ifcopenshell.util.element.get_pset(element, "EPset_Drawing", self.filter_mode.title()):
filter_groups = tool.Search.get_filter_groups(f"drawing_{self.filter_mode.lower()}")
try:
- data = json.loads(query)
- if isinstance(data, dict) and "filter_structure" in data:
- tool.Search.import_filter_structure(data["filter_structure"], filter_groups)
- else:
- tool.Search.import_filter_query(query, filter_groups)
- except Exception:
+ tool.Search.import_filter_query(query, filter_groups)
+ except:
pass
@@ -3712,41 +3912,12 @@ class EditElementFilter(bpy.types.Operator, tool.Ifc.Operator):
assert element
pset = tool.Pset.get_element_pset(element, "EPset_Drawing")
assert pset
-
if self.filter_mode == "INCLUDE":
- filter_groups = props.include_filter_groups
+ query = tool.Search.export_filter_query(props.include_filter_groups) or None
+ ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Include": query})
elif self.filter_mode == "EXCLUDE":
- filter_groups = props.exclude_filter_groups
- else:
- return
-
- query = tool.Search.export_filter_query(filter_groups) or None
-
- if tool.Blender.get_addon_preferences().chain_filter_with_set_operations and query:
- filter_structure = []
- for filter_group in filter_groups:
- group_data = []
- for ifc_filter in filter_group.filters:
- filter_data = {
- "type": ifc_filter.type,
- "name": ifc_filter.name,
- "value": ifc_filter.value,
- "pset": ifc_filter.pset,
- "comparison": ifc_filter.comparison,
- "filter_mode": ifc_filter.filter_mode,
- }
- group_data.append(filter_data)
- filter_structure.append(group_data)
-
- value = json.dumps({"type": "BBIM_Search", "query": query, "filter_structure": filter_structure})
- else:
- value = query
-
- if self.filter_mode == "INCLUDE":
- ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Include": value})
- elif self.filter_mode == "EXCLUDE":
- ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Exclude": value})
-
+ query = tool.Search.export_filter_query(props.exclude_filter_groups) or None
+ ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Exclude": query})
props.filter_mode = "NONE"
bpy.ops.bim.activate_drawing(drawing=element.id(), should_view_from_camera=False)
@@ -4080,60 +4251,1316 @@ class ActivateDrawingByAnnotation(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Activate Drawing"
bl_description = "Activate the drawing corresponding to the selected annotation"
bl_options = {"REGISTER", "UNDO"}
-
+
@classmethod
def poll(cls, context):
# Check if an annotation object is selected
if not context.selected_objects:
cls.poll_message_set("No object selected")
return False
-
+
active_obj = context.active_object
if not active_obj:
cls.poll_message_set("No active object")
return False
-
+
element = tool.Ifc.get_entity(active_obj)
if not element:
cls.poll_message_set("Selected object is not an IFC element")
return False
-
+
# Check if it's an IfcAnnotation with ObjectType = "SECTION" or "ELEVATION"
if not element.is_a("IfcAnnotation") or element.ObjectType not in ["SECTION", "ELEVATION"]:
cls.poll_message_set("Selected object is not a drawing annotation")
return False
-
+
return True
def _execute(self, context):
active_obj = context.active_object
element = tool.Ifc.get_entity(active_obj)
-
+
if not element or not element.is_a("IfcAnnotation") or element.ObjectType not in ["SECTION", "ELEVATION"]:
self.report({"ERROR"}, "Selected object is not a drawing annotation")
return {"CANCELLED"}
-
+
# Find the drawing/camera element that this annotation references
drawing_element = self.find_drawing_from_annotation(element)
-
+
if not drawing_element:
self.report({"ERROR"}, "Could not find drawing element for this annotation")
return {"CANCELLED"}
-
+
# Use the existing ActivateDrawing operator with the drawing element's ID
bpy.ops.bim.activate_drawing(drawing=drawing_element.id())
-
+
return {"FINISHED"}
-
+
def find_drawing_from_annotation(self, annotation_element):
"""Find the drawing/camera element that this annotation references."""
ifc = tool.Ifc.get()
-
+
# Check IfcRelAssignsToProduct relationships
for rel in ifc.get_inverse(annotation_element):
if rel.is_a("IfcRelAssignsToProduct") and rel.RelatingProduct:
if rel.RelatingProduct.is_a("IfcAnnotation"):
# Found the drawing element!
return rel.RelatingProduct
-
+
+
return None
+
+
+class SelectSimilarTextLiteralValue(bpy.types.Operator):
+ bl_idname = "bim.select_similar_text_literal_value"
+ bl_label = ""
+ bl_description = "Click to select all text annotations with this value\n\nSHIFT+CLICK to remove from selection"
+ bl_options = {"REGISTER", "UNDO"}
+
+ literal_value: bpy.props.StringProperty()
+ literal_index: bpy.props.IntProperty(default=0)
+ attribute_type: bpy.props.StringProperty(default="text")
+ display_text: bpy.props.StringProperty()
+ remove_from_selection: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
+
+ def invoke(self, context, event):
+ if hasattr(event, "type") and event.type == "LEFTMOUSE":
+ self.remove_from_selection = event.shift
+ elif hasattr(event, "shift"):
+ self.remove_from_selection = event.shift
+
+ return self.execute(context)
+
+ def execute(self, context):
+ if not self.literal_value and self.attribute_type in ["text", "path", "box_alignment", "font_size"]:
+ return {"CANCELLED"}
+
+ editing_status = {}
+ for obj in context.visible_objects:
+ obj_props = tool.Drawing.get_text_props(obj)
+ editing_status[obj] = obj_props.is_editing if hasattr(obj_props, "is_editing") else False
+
+ count = 0
+ for obj in context.visible_objects:
+ element = tool.Ifc.get_entity(obj)
+ if not element or not tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"]):
+ continue
+
+ obj_props = tool.Drawing.get_text_props(obj)
+ should_select = False
+
+ if self.attribute_type in ["text", "path", "box_alignment", "font_size", "literal", "resolved_text"]:
+ was_editing = obj_props.is_editing if hasattr(obj_props, "is_editing") else False
+ if not was_editing:
+ core.enable_editing_text(tool.Drawing, obj=obj)
+
+ if self.attribute_type == "font_size":
+ should_select = str(obj_props.font_size) == self.literal_value
+ elif self.attribute_type == "literal":
+ for idx, literal in enumerate(obj_props.literals):
+ if len(literal.attributes) > 0:
+ if literal.attributes[0].string_value == self.literal_value:
+ should_select = True
+ break
+ elif self.attribute_type == "resolved_text":
+ for idx, literal in enumerate(obj_props.literals):
+ if len(literal.attributes) > 0:
+ raw_value = literal.attributes[0].string_value
+ assigned_element = tool.Drawing.get_assigned_product(element) or element
+ resolved_value = tool.Drawing.replace_text_literal_variables(raw_value, assigned_element)
+ if resolved_value == self.literal_value:
+ should_select = True
+ break
+ else:
+ for idx, literal in enumerate(obj_props.literals):
+ if self.attribute_type == "text" and len(literal.attributes) > 0:
+ raw_value = literal.attributes[0].string_value
+ assigned_element = tool.Drawing.get_assigned_product(element) or element
+ resolved_value = tool.Drawing.replace_text_literal_variables(raw_value, assigned_element)
+ if resolved_value == self.literal_value:
+ should_select = True
+ break
+ elif self.attribute_type == "path" and len(literal.attributes) > 1:
+ attr = literal.attributes[1]
+ if attr.data_type == "enum":
+ if attr.enum_value == self.literal_value:
+ should_select = True
+ break
+ else:
+ if attr.string_value == self.literal_value:
+ should_select = True
+ break
+ elif self.attribute_type == "box_alignment":
+ box_alignment_attr = next(
+ (attr for attr in literal.attributes if attr.name == "BoxAlignment"), None
+ )
+ if box_alignment_attr and box_alignment_attr.string_value == self.literal_value:
+ should_select = True
+ break
+
+ if should_select:
+ obj.select_set(not self.remove_from_selection)
+ count += 1
+
+ for obj, was_editing in editing_status.items():
+ obj_props = tool.Drawing.get_text_props(obj)
+ if hasattr(obj_props, "is_editing"):
+ if was_editing and not obj_props.is_editing:
+ core.enable_editing_text(tool.Drawing, obj=obj)
+ elif not was_editing and obj_props.is_editing:
+ core.disable_editing_text(tool.Drawing, obj=obj)
+
+ if self.attribute_type in ["text", "path", "box_alignment"]:
+ result = f'literal[{self.literal_index}].{self.attribute_type} = "{self.literal_value}"'
+ else:
+ result = f'{self.attribute_type} = "{self.literal_value}"'
+
+ verb = "Deselected" if self.remove_from_selection else "Selected"
+ self.report(
+ {"INFO"},
+ f"{verb} {count} objects with {self.attribute_type} '{self.literal_value}'.",
+ )
+
+ return {"FINISHED"}
+
+
+class FilterSelectedObjectsIfIntersectedByCamera(bpy.types.Operator):
+ bl_idname = "bim.filter_selected_objects_if_intersected_by_camera"
+ bl_label = "Filter Selected Objects If Intersected by Camera"
+ bl_options = {"REGISTER", "UNDO"}
+ bl_description = "Deselect objects that are not intersected by the active camera view"
+
+ @classmethod
+ def poll(cls, context):
+ return context.scene.camera is not None and len(context.selected_objects) > 0
+
+ def execute(self, context):
+ self.filter_selected_objects_if_intersected_by_camera(context)
+ return {"FINISHED"}
+
+ def filter_selected_objects_if_intersected_by_camera(self, context: bpy.types.Context) -> None:
+ camera_obj = context.scene.camera
+ if not camera_obj:
+ return
+
+ camera = camera_obj.data
+ if not isinstance(camera, bpy.types.Camera):
+ return
+
+ cam_matrix = camera_obj.matrix_world
+ cam_origin = cam_matrix.translation
+ cam_direction = cam_matrix.to_quaternion() @ Vector((0.0, 0.0, -1.0))
+ plane_normal = cam_direction.normalized()
+ plane_point = cam_origin
+
+ def point_plane_distance(point):
+ return (point - plane_point).dot(plane_normal)
+
+ selected_objects = [obj for obj in context.selected_objects if obj != camera_obj]
+
+ deselected = 0
+ for obj in selected_objects:
+ bbox_world = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
+ distances = [point_plane_distance(p) for p in bbox_world]
+ min_d = min(distances)
+ max_d = max(distances)
+
+ intersects = (min_d <= 0.0 <= max_d) or (max_d <= 0.0 <= min_d)
+
+ if not intersects:
+ obj.select_set(False)
+ deselected += 1
+
+ remaining_selected = len([obj for obj in context.selected_objects if obj != camera_obj])
+ self.report(
+ {"INFO"}, f"Filtered to {remaining_selected} object(s) intersecting camera plane (deselected {deselected})"
+ )
+ return {"FINISHED"}
+
+
+class SelectElementValues(bpy.types.Operator):
+ bl_idname = "bim.select_element_values"
+ bl_label = "Select Element Values"
+ bl_description = "Select a property or quantity from the assigned product to insert into the text literal"
+ bl_options = {"REGISTER", "UNDO"}
+
+ literal_prop_id: bpy.props.IntProperty()
+ expanded_category: bpy.props.StringProperty(default="Basic")
+
+ def invoke(self, context, event):
+ return context.window_manager.invoke_props_dialog(self, width=600)
+
+ def draw(self, context):
+ obj = context.active_object
+ element = tool.Ifc.get_entity(obj)
+ assigned_product = tool.Drawing.get_assigned_product(element)
+ if not assigned_product:
+ self.layout.label(text="No product assigned to this annotation", icon="ERROR")
+ return
+
+ if not ElementValuesData.is_loaded:
+ ElementValuesData.load()
+ available_keys = ElementValuesData.get_available_element_value_keys(assigned_product)
+
+ for category_name, keys in available_keys.items():
+ if not keys:
+ continue
+
+ box = self.layout.box()
+ box.label(text=category_name, icon=self.get_category_icon(category_name))
+
+ for key, description in keys:
+ row = box.row()
+ row.label(text=description)
+
+ def execute(self, context):
+ return {"FINISHED"}
+
+ def get_category_icon(self, category_name):
+ icons = {
+ "Basic": "OBJECT_DATA",
+ "Attributes": "PROPERTIES",
+ "Property Sets": "PROPERTIES",
+ "Quantity Sets": "SNAP_VOLUME",
+ "Type": "OUTLINER_OB_MESH",
+ "Spatial": "HOME",
+ "Parent": "FILE_PARENT",
+ "Classification": "BOOKMARKS",
+ "Groups": "GROUP",
+ "Systems": "SYSTEM",
+ "Zones": "MESH_CIRCLE",
+ "Material": "MATERIAL",
+ "Coordinates": "EMPTY_ARROWS",
+ }
+ return icons.get(category_name, "DOT")
+
+
+class ToggleElementValuesPanel(bpy.types.Operator):
+ bl_idname = "bim.toggle_element_values_panel"
+ bl_label = "Toggle Element Values Panel"
+ bl_options = {"REGISTER", "UNDO"}
+
+ literal_prop_id: bpy.props.IntProperty()
+
+ def execute(self, context):
+ obj = context.active_object
+ if not obj:
+ return {"CANCELLED"}
+
+ props = tool.Drawing.get_text_props(obj)
+ if self.literal_prop_id >= len(props.literals):
+ return {"CANCELLED"}
+
+ literal_props = props.literals[self.literal_prop_id]
+
+ current_state = getattr(literal_props, "show_element_values", False)
+ literal_props.show_element_values = not current_state
+
+ return {"FINISHED"}
+
+
+class ToggleElementValuesCategory(bpy.types.Operator):
+ bl_idname = "bim.toggle_element_values_category"
+ bl_label = "Toggle Element Values Category"
+ bl_options = {"REGISTER", "UNDO"}
+
+ category_name: bpy.props.StringProperty()
+ literal_prop_id: bpy.props.IntProperty()
+ is_currently_expanded: bpy.props.BoolProperty()
+
+ def execute(self, context):
+ obj = context.active_object
+ if not obj:
+ return {"CANCELLED"}
+
+ props = tool.Drawing.get_text_props(obj)
+ if self.literal_prop_id >= len(props.literals):
+ return {"CANCELLED"}
+
+ literal_props = props.literals[self.literal_prop_id]
+
+ if self.is_currently_expanded:
+ literal_props.expanded_category = ""
+ else:
+ literal_props.expanded_category = self.category_name
+
+ return {"FINISHED"}
+
+
+class InsertFormattedLiteralPopup(bpy.types.Operator):
+ bl_idname = "bim.insert_formatted_literal_popup"
+ bl_label = "Insert Formatted Element Value"
+ bl_description = "Insert element value with formatting options"
+ bl_options = {"REGISTER", "UNDO"}
+
+ literal_prop_id: bpy.props.IntProperty()
+ element_value_key: bpy.props.StringProperty()
+ value_number: bpy.props.IntProperty()
+
+ formatting_type: bpy.props.EnumProperty(
+ name="Formatting",
+ items=[
+ ("NONE", "No Formatting", "Insert value as-is"),
+ ("UPPER", "Uppercase", "Convert to uppercase"),
+ ("LOWER", "Lowercase", "Convert to lowercase"),
+ ("TITLE", "Title Case", "Convert to title case"),
+ ("ROUND", "Round Number", "Round to specified precision"),
+ ("INT", "Integer", "Truncate decimal part"),
+ ("NUMBER", "Format Number", "Format with separators"),
+ ("METRIC_LENGTH", "Metric Length", "Format as metric length"),
+ ("IMPERIAL_LENGTH", "Imperial Length", "Format as imperial length"),
+ ("CUSTOM", "Custom Expression", "Create custom expression with functions"),
+ ],
+ default="NONE",
+ )
+
+ round_precision: bpy.props.StringProperty(
+ name="Precision", description="Rounding precision (e.g. 0.1, 0.01, 0.001, 1, 10, 100)", default="0.01"
+ )
+
+ decimal_separator: bpy.props.StringProperty(
+ name="Decimal Separator", description="Decimal separator character", default=".", maxlen=1
+ )
+
+ thousands_separator: bpy.props.StringProperty(
+ name="Thousands Separator", description="Thousands separator character", default=",", maxlen=1
+ )
+
+ metric_decimals: bpy.props.IntProperty(
+ name="Decimal Places", description="Number of decimal places to show", default=2, min=0, max=10
+ )
+
+ metric_precision: bpy.props.StringProperty(
+ name="Metric Precision",
+ description="Rounding precision for metric length (e.g. 0.1, 0.01, 0.001)",
+ default="0.01",
+ )
+
+ imperial_precision: bpy.props.IntProperty(
+ name="Fraction Precision", description="Imperial fraction precision (1/N)", default=4, min=1, max=64
+ )
+
+ imperial_input_unit: bpy.props.EnumProperty(
+ name="Input Unit",
+ items=[
+ ("foot", "Feet", "Input value is in feet"),
+ ("inch", "Inches", "Input value is in inches"),
+ ],
+ default="foot",
+ )
+
+ imperial_output_unit: bpy.props.EnumProperty(
+ name="Output Format",
+ items=[
+ ("foot", "Feet and Inches", "Display as feet and inches"),
+ ("inch", "Inches Only", "Display as inches only"),
+ ],
+ default="foot",
+ )
+
+ custom_expression: bpy.props.StringProperty(
+ name="Custom Expression",
+ description=(
+ "Custom expression using functions like concat(), upper(), round(), etc.\n"
+ "Use {{value}} as placeholder for the selected element value.\n"
+ "Examples:\n"
+ '- concat("Name: ", {{value}})\n'
+ '- upper(concat("Type: ", {{value}}))'
+ ),
+ default='concat({{value}}, " - additional text")',
+ )
+
+ def invoke(self, context, event):
+ return context.window_manager.invoke_props_dialog(self, width=450)
+
+ def draw(self, context):
+ layout = self.layout
+
+ box = layout.box()
+ box.label(text=f"Element Value: {self.element_value_key}", icon="PROPERTIES")
+
+ layout.prop(self, "formatting_type")
+
+ if self.formatting_type == "ROUND":
+ layout.prop(self, "round_precision")
+ elif self.formatting_type == "NUMBER":
+ col = layout.column()
+ col.prop(self, "decimal_separator")
+ col.prop(self, "thousands_separator")
+ elif self.formatting_type == "METRIC_LENGTH":
+ col = layout.column()
+ col.prop(self, "metric_precision")
+ col.prop(self, "metric_decimals")
+ elif self.formatting_type == "IMPERIAL_LENGTH":
+ col = layout.column()
+ col.prop(self, "imperial_precision")
+ col.prop(self, "imperial_input_unit")
+ col.prop(self, "imperial_output_unit")
+ elif self.formatting_type == "CUSTOM":
+ col = layout.column()
+ col.prop(self, "custom_expression", text="")
+
+ obj = bpy.context.active_object
+ if obj:
+ element = tool.Ifc.get_entity(obj)
+
+ props = tool.Drawing.get_text_props(obj)
+ product = None
+
+ if props.literals:
+ for literal_props in props.literals:
+ if hasattr(literal_props, "product_used") and literal_props.product_used:
+ product = tool.Ifc.get_entity(literal_props.product_used)
+ break
+
+ if not product:
+ product = tool.Drawing.get_assigned_product(element)
+
+ if not product:
+ product = element
+
+ if product:
+ all_categories = ElementValuesData.get_available_element_value_keys(product)
+
+ available_keys = []
+ for cat_name, cat_keys in all_categories.items():
+ for cat_key, cat_desc in cat_keys:
+ available_keys.append(
+ (
+ cat_key,
+ f"[{cat_name}] {cat_desc.split(': ', 1)[-1] if ': ' in cat_desc else cat_desc}",
+ )
+ )
+
+ if available_keys:
+ help_box = col.box()
+ help_box.scale_y = 0.7
+ help_box.label(text="Available attributes:", icon="INFO")
+
+ for i, (key, desc) in enumerate(available_keys[:5]):
+ help_box.label(text="{{value{}}} = {} ({})".format(i + 1, key, desc))
+
+ if len(available_keys) > 5:
+ help_box.label(text=f"... and {len(available_keys) - 5} more attributes")
+
+ help_box = col.box()
+ help_box.scale_y = 0.8
+ help_box.label(text="Available functions:", icon="INFO")
+ help_box.label(text="• concat(text1, text2, ...) - combine values")
+ help_box.label(text="• upper(value) - uppercase")
+ help_box.label(text="• lower(value) - lowercase")
+ help_box.label(text="• round(value, precision) - round number")
+ help_box.label(text="• Use {{value}} for current element value")
+
+ preview_box = layout.box()
+ preview_box.label(text="Preview:", icon="PROPERTIES")
+ formatted_syntax = self._generate_formatted_syntax()
+
+ preview_text = formatted_syntax
+ if len(preview_text) > 60:
+ words = preview_text.split()
+ lines = []
+ current_line = ""
+ for word in words:
+ if len(current_line + " " + word) > 60 and current_line:
+ lines.append(current_line)
+ current_line = word
+ else:
+ current_line = (current_line + " " + word).strip()
+ if current_line:
+ lines.append(current_line)
+
+ for line in lines:
+ preview_box.label(text=line)
+ else:
+ preview_box.label(text=preview_text)
+
+ def _get_all_available_keys(self, element):
+ """Get all available element value keys in a flat list with their descriptions"""
+ available_keys = []
+
+ all_categories = ElementValuesData.get_available_element_value_keys(element)
+
+ for category_name, keys in all_categories.items():
+ for key, description in keys:
+ available_keys.append(
+ (key, f"[{category_name}] {description.split(': ', 1)[-1] if ': ' in description else description}")
+ )
+
+ return available_keys
+
+ def _generate_formatted_syntax(self) -> str:
+ """Generate the formatted selector syntax based on current settings"""
+ base_value = f"{{{{{self.element_value_key}}}}}"
+
+ if self.formatting_type == "NONE":
+ return base_value
+ elif self.formatting_type == "UPPER":
+ return f"``upper({base_value})`` "
+ elif self.formatting_type == "LOWER":
+ return f"``lower({base_value})`` "
+ elif self.formatting_type == "TITLE":
+ return f"``title({base_value})`` "
+ elif self.formatting_type == "ROUND":
+ return f"``round({base_value}, {self.round_precision})`` "
+ elif self.formatting_type == "INT":
+ return f"``int({base_value})`` "
+ elif self.formatting_type == "NUMBER":
+ return f"``number({base_value}, {self.decimal_separator}, {self.thousands_separator})`` "
+ elif self.formatting_type == "METRIC_LENGTH":
+ return f"``metric_length({base_value}, {self.metric_precision}, {self.metric_decimals})`` "
+ elif self.formatting_type == "IMPERIAL_LENGTH":
+ return f'``imperial_length({base_value}, {self.imperial_precision}, "{self.imperial_input_unit}", "{self.imperial_output_unit}")`` '
+ elif self.formatting_type == "CUSTOM":
+ custom_expr = self.custom_expression.replace("{{value}}", base_value)
+ return f"``{custom_expr}``"
+
+ return base_value
+
+ def execute(self, context):
+ obj = context.active_object
+ assert obj
+ props = tool.Drawing.get_text_props(obj)
+ literal_props = props.literals[self.literal_prop_id]
+
+ formatted_syntax = self._generate_formatted_syntax()
+
+ for attr in literal_props.attributes:
+ if attr.name == "Literal":
+ current_text = attr.string_value or ""
+
+ if current_text:
+ attr.string_value = f"{current_text} {formatted_syntax}"
+ else:
+ attr.string_value = formatted_syntax
+ break
+
+ tool.Blender.update_viewport()
+ return {"FINISHED"}
+
+
+class ShowCategoryHelp(bpy.types.Operator):
+ bl_idname = "bim.show_category_help"
+ bl_label = "Category Help"
+ bl_description = "Show help for this element value category"
+ bl_options = {"REGISTER"}
+
+ category_name: bpy.props.StringProperty()
+
+ def invoke(self, context, event):
+ return context.window_manager.invoke_props_dialog(self, width=600)
+
+ def draw(self, context):
+ layout = self.layout
+
+ category_help = {
+ "Basic": {
+ "title": "BASIC KEYS",
+ "icon": "DOT",
+ "items": [
+ ("id", "IFC entity ID", "{{id}} → '12345'"),
+ ("class", "IFC class name", "{{class}} → 'IfcWall'"),
+ ("predefined_type", "Predefined type", "{{predefined_type}} → 'SOLIDWALL'"),
+ ],
+ },
+ "Attributes": {
+ "title": "ATTRIBUTES",
+ "icon": "PROPERTIES",
+ "items": [
+ ("Name", "Element name", "{{Name}} → 'Wall-001'"),
+ ("Description", "Element description", "{{Description}} → 'Exterior wall'"),
+ ("Tag", "Element tag", "{{Tag}} → 'W-01'"),
+ ("ObjectType", "Object type", "{{ObjectType}} → 'Load Bearing'"),
+ ],
+ "note": "All IFC attributes are accessible by their name",
+ },
+ "Property Sets": {
+ "title": "PROPERTY SETS",
+ "icon": "ALIGN_JUSTIFY",
+ "items": [
+ ("Pset_*.PropertyName", "Property value", "{{Pset_WallCommon.FireRating}} → 'REI 120'"),
+ ("Pset_*.IsExternal", "Boolean property", "{{Pset_WallCommon.IsExternal}} → 'True'"),
+ ],
+ "note": "Access any property from any property set using Pset_Name.PropertyName syntax",
+ },
+ "Quantity Sets": {
+ "title": "QUANTITY SETS",
+ "icon": "ALIGN_JUSTIFY",
+ "items": [
+ ("Qto_*.QuantityName", "Quantity value", "{{Qto_WallBaseQuantities.NetArea}} → '45.5'"),
+ ("Qto_*.NetVolume", "Volume quantity", "{{Qto_WallBaseQuantities.NetVolume}} → '9.1'"),
+ ],
+ "note": "Access any quantity from quantity sets using Qto_Name.QuantityName syntax",
+ },
+ "Type": {
+ "title": "TYPE INFORMATION",
+ "icon": "OUTLINER_OB_MESH",
+ "items": [
+ ("type.Name", "Type element name", "{{type.Name}} → 'WT-200mm-Concrete'"),
+ ("types.count", "Number of instances of this type", "{{types.count}} → '15'"),
+ ("occurrences.count", "Same as types.count", "{{occurrences.count}} → '15'"),
+ ],
+ "example": "Type: {{type.Name}} ({{types.count}} instances)",
+ },
+ "Spatial": {
+ "title": "SPATIAL HIERARCHY",
+ "icon": "HOME",
+ "items": [
+ ("container.Name", "Immediate spatial container", "{{container.Name}} → 'Level 2'"),
+ ("space.Name", "Containing space", "{{space.Name}} → 'Office 205'"),
+ ("storey.Name", "Building storey", "{{storey.Name}} → 'Level 2'"),
+ ("building.Name", "Building", "{{building.Name}} → 'Building A'"),
+ ("site.Name", "Site", "{{site.Name}} → 'Main Campus'"),
+ ],
+ "example": "{{building.Name}} / {{storey.Name}} / {{space.Name}}",
+ },
+ "Parent": {
+ "title": "PARENT (AGGREGATION)",
+ "icon": "OUTLINER_DATA_GP_LAYER",
+ "items": [
+ ("parent.name", "Aggregate parent element", "{{parent.name}} → 'Curtain Wall-01'"),
+ ],
+ "note": "Used for aggregated elements like mullions in curtain walls",
+ },
+ "Material": {
+ "title": "MATERIALS",
+ "icon": "MATERIAL",
+ "items": [
+ ("material.Name", "Material name", "{{material.Name}} → 'Concrete'"),
+ ("materials.count", "Number of layers/profiles", "{{materials.count}} → '3'"),
+ (
+ "material.item.Material.Name.0",
+ "First layer material",
+ "{{material.item.Material.Name.0}} → 'Brick'",
+ ),
+ (
+ "material.item.Material.Name.1",
+ "Second layer material",
+ "{{material.item.Material.Name.1}} → 'Insulation'",
+ ),
+ ("material.item.0.LayerThickness", "Layer thickness", "{{material.item.0.LayerThickness}} → '0.1'"),
+ ],
+ "example": "{{materials.count}} layers: {{material.item.Material.Name.0}}",
+ },
+ "Styles": {
+ "title": "PRESENTATION STYLES",
+ "icon": "COLOR",
+ "items": [
+ ("styles.count", "Number of styles", "{{styles.count}} → '1'"),
+ ("styles.0.Name", "Style name (indexed)", "{{styles.0.Name}} → 'Red'"),
+ ("styles.0.Color", "RGB color value", "{{styles.0.Color}} → 'RGB(1.00, 0.00, 0.00)'"),
+ ],
+ },
+ "Profiles": {
+ "title": "PROFILES",
+ "icon": "OUTLINER_DATA_CURVES",
+ "items": [
+ ("profiles.count", "Number of profiles", "{{profiles.count}} → '2'"),
+ ("profiles.0.ProfileName", "Profile name (indexed)", "{{profiles.0.ProfileName}} → 'HEA200'"),
+ ("profiles.0.ProfileType", "Profile type (indexed)", "{{profiles.0.ProfileType}} → 'AREA'"),
+ ("profile.ProfileName", "Single swept profile", "{{profile.ProfileName}} → 'Rectangle'"),
+ ],
+ },
+ "Groups": {
+ "title": "GROUPS",
+ "icon": "OUTLINER_OB_GROUP_INSTANCE",
+ "items": [
+ ("group.Name", "Group name", "{{group.Name}} → 'Phase 1'"),
+ ("groups.count", "Number of group assignments", "{{groups.count}} → '2'"),
+ ],
+ },
+ "Systems": {
+ "title": "SYSTEMS",
+ "icon": "OUTLINER_OB_GROUP_INSTANCE",
+ "items": [
+ ("system.Name", "System name", "{{system.Name}} → 'HVAC-01'"),
+ ("systems.count", "Number of system assignments", "{{systems.count}} → '1'"),
+ ],
+ },
+ "Zones": {
+ "title": "ZONES",
+ "icon": "OUTLINER_OB_GROUP_INSTANCE",
+ "items": [
+ ("zone.Name", "Zone name", "{{zone.Name}} → 'Fire Zone A'"),
+ ("zones.count", "Number of zone assignments", "{{zones.count}} → '1'"),
+ ],
+ },
+ "Classification": {
+ "title": "CLASSIFICATION",
+ "icon": "PRESET",
+ "items": [
+ ("classification.0.Name", "Classification name (indexed)", "{{classification.0.Name}} → 'Walls'"),
+ (
+ "classification.0.Identification",
+ "Classification code (indexed)",
+ "{{classification.0.Identification}} → 'E20'",
+ ),
+ ("classification.count", "Number of classification references", "{{classification.count}} → '2'"),
+ ],
+ },
+ "Coordinates": {
+ "title": "COORDINATES",
+ "icon": "ORIENTATION_VIEW",
+ "items": [
+ ("x", "Local X coordinate", "{{x}} → '10.5'"),
+ ("y", "Local Y coordinate", "{{y}} → '5.2'"),
+ ("z", "Local Z coordinate", "{{z}} → '3.0'"),
+ ("easting", "Map easting coordinate", "{{easting}} → '500123.45'"),
+ ("northing", "Map northing coordinate", "{{northing}} → '6750234.56'"),
+ ("elevation", "Map elevation", "{{elevation}} → '123.45'"),
+ ],
+ },
+ }
+
+ if self.category_name in category_help:
+ info = category_help[self.category_name]
+ layout.label(text=info["title"], icon=info["icon"])
+
+ box = layout.box()
+ for key, desc, example in info["items"]:
+ col = box.column(align=True)
+ col.scale_y = 0.85
+ col.label(text=f"{key} - {desc}")
+ col.label(text=f" {example}")
+ box.separator(factor=0.3)
+
+ if "note" in info:
+ note_box = layout.box()
+ note_box.label(text="Note:", icon="INFO")
+ note_box.label(text=info["note"])
+
+ if "example" in info:
+ ex_box = layout.box()
+ ex_box.label(text="Example:", icon="SCRIPTPLUGINS")
+ ex_box.label(text=info["example"])
+ else:
+ layout.label(text=f"No help available for '{self.category_name}'")
+
+ def execute(self, context):
+ return {"FINISHED"}
+
+
+class AddElementValueRow(bpy.types.Operator):
+ bl_idname = "bim.add_element_value_row"
+ bl_label = "Add Element"
+ bl_description = "Add a new element value row"
+ bl_options = {"REGISTER", "UNDO"}
+
+ literal_prop_id: bpy.props.IntProperty()
+
+ def execute(self, context):
+ obj = context.active_object
+ if not obj:
+ return {"CANCELLED"}
+
+ props = tool.Drawing.get_text_props(obj)
+ if self.literal_prop_id >= len(props.literals):
+ return {"CANCELLED"}
+
+ literal_props = props.literals[self.literal_prop_id]
+ new_row = literal_props.element_value_rows.add()
+ new_row.category = literal_props.category_for_adding
+ new_row.element_key = ""
+ new_row.formatted_value = ""
+
+ if len(literal_props.element_value_rows) == 1:
+ new_row.separator = ""
+ else:
+ new_row.separator = " - "
+
+ return {"FINISHED"}
+
+
+class RemoveElementValueRow(bpy.types.Operator):
+ bl_idname = "bim.remove_element_value_row"
+ bl_label = "Remove Element Value Row"
+ bl_description = "Remove this element value row"
+ bl_options = {"REGISTER", "UNDO"}
+
+ literal_prop_id: bpy.props.IntProperty()
+ row_index: bpy.props.IntProperty()
+
+ def execute(self, context):
+ obj = context.active_object
+ if not obj:
+ return {"CANCELLED"}
+
+ props = tool.Drawing.get_text_props(obj)
+ if self.literal_prop_id >= len(props.literals):
+ return {"CANCELLED"}
+
+ literal_props = props.literals[self.literal_prop_id]
+ if self.row_index < len(literal_props.element_value_rows):
+ literal_props.element_value_rows.remove(self.row_index)
+
+ return {"FINISHED"}
+
+
+class ElementValueSuggestionsPopup(bpy.types.Operator):
+ bl_idname = "bim.element_value_suggestions_popup"
+ bl_label = "Element Value Suggestions"
+ bl_description = "Show suggestions for element values in the selected category"
+ bl_options = {"REGISTER", "UNDO"}
+
+ literal_prop_id: bpy.props.IntProperty()
+ row_index: bpy.props.IntProperty()
+ category: bpy.props.StringProperty()
+ search_query: bpy.props.StringProperty(name="Search", description="Search for element values")
+
+ collection_keys: bpy.props.CollectionProperty(type=StrProperty)
+ collection_descriptions: bpy.props.CollectionProperty(type=StrProperty)
+
+ selected_key: bpy.props.StringProperty()
+
+ def invoke(self, context, event):
+ obj = context.active_object
+ if not obj:
+ return {"CANCELLED"}
+
+ props = tool.Drawing.get_text_props(obj)
+ if self.literal_prop_id >= len(props.literals):
+ return {"CANCELLED"}
+
+ literal_props = props.literals[self.literal_prop_id]
+ current_product = get_current_product_for_element_values(obj, literal_props)
+
+ if not current_product:
+ self.report({"ERROR"}, "No product selected. Use eyedropper to select an object.")
+ return {"CANCELLED"}
+
+ element = tool.Ifc.get_entity(current_product)
+ if not element:
+ self.report({"ERROR"}, "Selected object has no IFC data")
+ return {"CANCELLED"}
+
+ if not ElementValuesData.is_loaded:
+ ElementValuesData.load()
+
+ available_keys = ElementValuesData.get_available_element_value_keys(element)
+
+ if self.category not in available_keys:
+ self.report({"ERROR"}, f"Category '{self.category}' not found")
+ return {"CANCELLED"}
+
+ keys = available_keys[self.category]
+ if not keys:
+ self.report({"INFO"}, f"No values available for category '{self.category}'")
+ return {"CANCELLED"}
+
+ self.collection_keys.clear()
+ self.collection_descriptions.clear()
+ for key, description in keys:
+ self.collection_keys.add().name = key
+ self.collection_descriptions.add().name = description
+
+ return context.window_manager.invoke_props_dialog(self, width=500)
+
+ def draw(self, context):
+ layout = self.layout
+
+ layout.prop_search(self, "selected_key", self, "collection_descriptions", text="Value")
+
+ def execute(self, context):
+ if not self.selected_key:
+ return {"CANCELLED"}
+
+ obj = context.active_object
+ if not obj:
+ return {"CANCELLED"}
+
+ props = tool.Drawing.get_text_props(obj)
+ if self.literal_prop_id >= len(props.literals):
+ return {"CANCELLED"}
+
+ literal_props = props.literals[self.literal_prop_id]
+ if self.row_index >= len(literal_props.element_value_rows):
+ return {"CANCELLED"}
+
+ value_row = literal_props.element_value_rows[self.row_index]
+
+ for idx, desc_item in enumerate(self.collection_descriptions):
+ if desc_item.name == self.selected_key:
+ actual_key = self.collection_keys[idx].name
+ value_row.element_key = actual_key
+ value_row.formatted_value = f"{{{{{actual_key}}}}}"
+ break
+
+ return {"FINISHED"}
+
+
+class FormatElementValueRow(bpy.types.Operator):
+ bl_idname = "bim.format_element_value_row"
+ bl_label = "Format Element Value"
+ bl_description = "Format element value with functions"
+ bl_options = {"REGISTER", "UNDO"}
+
+ literal_prop_id: bpy.props.IntProperty()
+ row_index: bpy.props.IntProperty()
+
+ formatting_type: bpy.props.EnumProperty(
+ name="Formatting",
+ items=[
+ ("NONE", "No Formatting", "Insert value as-is"),
+ ("UPPER", "Uppercase", "Convert to uppercase"),
+ ("LOWER", "Lowercase", "Convert to lowercase"),
+ ("TITLE", "Title Case", "Convert to title case"),
+ ("ROUND", "Round Number", "Round to specified precision"),
+ ("INT", "Integer", "Truncate decimal part"),
+ ("NUMBER", "Format Number", "Format with separators"),
+ ("METRIC_LENGTH", "Metric Length", "Format as metric length"),
+ ("IMPERIAL_LENGTH", "Imperial Length", "Format as imperial length"),
+ ("CUSTOM", "Custom Expression", "Create custom expression with functions"),
+ ],
+ default="NONE",
+ )
+
+ round_precision: bpy.props.StringProperty(
+ name="Precision", description="Rounding precision (e.g. 0.1, 0.01, 0.001, 1, 10, 100)", default="0.01"
+ )
+
+ decimal_separator: bpy.props.StringProperty(
+ name="Decimal Separator", description="Decimal separator character", default=".", maxlen=1
+ )
+
+ thousands_separator: bpy.props.StringProperty(
+ name="Thousands Separator", description="Thousands separator character", default=",", maxlen=1
+ )
+
+ metric_decimals: bpy.props.IntProperty(
+ name="Decimal Places", description="Number of decimal places to show", default=2, min=0, max=10
+ )
+
+ metric_precision: bpy.props.StringProperty(
+ name="Metric Precision",
+ description="Rounding precision for metric length (e.g. 0.1, 0.01, 0.001)",
+ default="0.01",
+ )
+
+ imperial_precision: bpy.props.IntProperty(
+ name="Fraction Precision", description="Imperial fraction precision (1/N)", default=4, min=1, max=64
+ )
+
+ imperial_input_unit: bpy.props.EnumProperty(
+ name="Input Unit",
+ items=[
+ ("foot", "Feet", "Input value is in feet"),
+ ("inch", "Inches", "Input value is in inches"),
+ ],
+ default="foot",
+ )
+
+ imperial_output_unit: bpy.props.EnumProperty(
+ name="Output Format",
+ items=[
+ ("foot", "Feet and Inches", "Display as feet and inches"),
+ ("inch", "Inches Only", "Display as inches only"),
+ ],
+ default="foot",
+ )
+
+ custom_expression: bpy.props.StringProperty(
+ name="Custom Expression",
+ description=(
+ "Custom expression using functions\n"
+ "Use {{value}} as placeholder for the current row's value."
+ ),
+ default='concat({{value}}, " - additional text")',
+ )
+
+ def invoke(self, context, event):
+ obj = context.active_object
+ if not obj:
+ return {"CANCELLED"}
+
+ props = tool.Drawing.get_text_props(obj)
+ if self.literal_prop_id >= len(props.literals):
+ return {"CANCELLED"}
+
+ literal_props = props.literals[self.literal_prop_id]
+ if self.row_index >= len(literal_props.element_value_rows):
+ return {"CANCELLED"}
+
+ row = literal_props.element_value_rows[self.row_index]
+ self._load_formatting_from_row(row)
+
+ return context.window_manager.invoke_props_dialog(self, width=450)
+
+ def _load_formatting_from_row(self, row):
+ """Parse the formatted_value to load existing formatting settings"""
+ import re
+
+ formatted_value = row.formatted_value
+
+ if not formatted_value or formatted_value == f"{{{{{row.element_key}}}}}":
+ self.formatting_type = "NONE"
+ return
+
+ if formatted_value.startswith("``") and formatted_value.endswith("``"):
+ expression = formatted_value[2:-2].strip()
+ else:
+ self.formatting_type = "NONE"
+ return
+
+ if match := re.match(r"upper\(\{\{[^}]+\}\}\)", expression):
+ self.formatting_type = "UPPER"
+
+ elif match := re.match(r"lower\(\{\{[^}]+\}\}\)", expression):
+ self.formatting_type = "LOWER"
+
+ elif match := re.match(r"title\(\{\{[^}]+\}\}\)", expression):
+ self.formatting_type = "TITLE"
+
+ elif match := re.match(r"int\(\{\{[^}]+\}\}\)", expression):
+ self.formatting_type = "INT"
+
+ elif match := re.match(r"round\(\{\{[^}]+\}\},\s*([^)]+)\)", expression):
+ self.formatting_type = "ROUND"
+ self.round_precision = match.group(1).strip()
+
+ elif match := re.match(r"number\(\{\{[^}]+\}\},\s*([^,]+),\s*([^)]+)\)", expression):
+ self.formatting_type = "NUMBER"
+ self.decimal_separator = match.group(1).strip()
+ self.thousands_separator = match.group(2).strip()
+
+ elif match := re.match(r"metric_length\(\{\{[^}]+\}\},\s*([^,]+),\s*([^)]+)\)", expression):
+ self.formatting_type = "METRIC_LENGTH"
+ self.metric_precision = match.group(1).strip()
+ self.metric_decimals = int(match.group(2).strip())
+
+ elif match := re.match(r'imperial_length\(\{\{[^}]+\}\},\s*(\d+),\s*"([^"]+)",\s*"([^"]+)"\)', expression):
+ self.formatting_type = "IMPERIAL_LENGTH"
+ self.imperial_precision = int(match.group(1).strip())
+ self.imperial_input_unit = match.group(2).strip()
+ self.imperial_output_unit = match.group(3).strip()
+
+ else:
+ self.formatting_type = "CUSTOM"
+ self.custom_expression = expression
+
+ def draw(self, context):
+ layout = self.layout
+ obj = context.active_object
+ props = tool.Drawing.get_text_props(obj)
+ literal_props = props.literals[self.literal_prop_id]
+ row = literal_props.element_value_rows[self.row_index]
+
+ box = layout.box()
+ box.label(text=f"Element Value: {row.element_key}", icon="PROPERTIES")
+
+ layout.prop(self, "formatting_type")
+
+ if self.formatting_type == "ROUND":
+ layout.prop(self, "round_precision")
+ elif self.formatting_type == "NUMBER":
+ col = layout.column()
+ col.prop(self, "decimal_separator")
+ col.prop(self, "thousands_separator")
+ elif self.formatting_type == "METRIC_LENGTH":
+ col = layout.column()
+ col.prop(self, "metric_precision")
+ col.prop(self, "metric_decimals")
+ elif self.formatting_type == "IMPERIAL_LENGTH":
+ col = layout.column()
+ col.prop(self, "imperial_precision")
+ col.prop(self, "imperial_input_unit")
+ col.prop(self, "imperial_output_unit")
+ elif self.formatting_type == "CUSTOM":
+ col = layout.column()
+ col.prop(self, "custom_expression", text="")
+
+ preview_box = layout.box()
+ preview_box.label(text="Preview:", icon="PROPERTIES")
+ formatted_syntax = self._generate_formatted_syntax(row.element_key)
+ preview_box.label(text=formatted_syntax)
+
+ def _generate_formatted_syntax(self, element_key: str) -> str:
+ """Generate the formatted selector syntax based on current settings"""
+ base_value = f"{{{{{element_key}}}}}"
+
+ if self.formatting_type == "NONE":
+ return base_value
+ elif self.formatting_type == "UPPER":
+ return f"``upper({base_value})``"
+ elif self.formatting_type == "LOWER":
+ return f"``lower({base_value})``"
+ elif self.formatting_type == "TITLE":
+ return f"``title({base_value})``"
+ elif self.formatting_type == "ROUND":
+ return f"``round({base_value}, {self.round_precision})``"
+ elif self.formatting_type == "INT":
+ return f"``int({base_value})``"
+ elif self.formatting_type == "NUMBER":
+ return f"``number({base_value}, {self.decimal_separator}, {self.thousands_separator})``"
+ elif self.formatting_type == "METRIC_LENGTH":
+ return f"``metric_length({base_value}, {self.metric_precision}, {self.metric_decimals})``"
+ elif self.formatting_type == "IMPERIAL_LENGTH":
+ return f'``imperial_length({base_value}, {self.imperial_precision}, "{self.imperial_input_unit}", "{self.imperial_output_unit}")``'
+ elif self.formatting_type == "CUSTOM":
+ custom_expr = self.custom_expression.replace("{{value}}", base_value)
+ return f"``{custom_expr}``"
+
+ return base_value
+
+ def execute(self, context):
+ obj = context.active_object
+ if not obj:
+ return {"CANCELLED"}
+
+ props = tool.Drawing.get_text_props(obj)
+ literal_props = props.literals[self.literal_prop_id]
+ row = literal_props.element_value_rows[self.row_index]
+
+ formatted_syntax = self._generate_formatted_syntax(row.element_key)
+ row.formatted_value = formatted_syntax
+
+ tool.Blender.update_viewport()
+ return {"FINISHED"}
+
+
+class ApplyElementValueRowsToLiteral(bpy.types.Operator):
+ bl_idname = "bim.apply_element_value_rows_to_literal"
+ bl_label = "Apply Element Values to Literal"
+ bl_description = "Concatenate all element value rows and apply to the literal"
+ bl_options = {"REGISTER", "UNDO"}
+
+ literal_prop_id: bpy.props.IntProperty()
+
+ def execute(self, context):
+ obj = context.active_object
+ if not obj:
+ return {"CANCELLED"}
+
+ props = tool.Drawing.get_text_props(obj)
+ if self.literal_prop_id >= len(props.literals):
+ return {"CANCELLED"}
+
+ literal_props = props.literals[self.literal_prop_id]
+
+ parts = []
+ for row in literal_props.element_value_rows:
+ if row.element_key:
+ if row.category == "Custom String":
+ parts.append(row.element_key)
+ else:
+ if row.formatted_value and row.formatted_value != f"{{{{{row.element_key}}}}}":
+ updated_formatted = self._update_formatted_value(row.element_key, row.formatted_value)
+ row.formatted_value = updated_formatted
+ value_part = updated_formatted
+ else:
+ default_format = f"{{{{{row.element_key}}}}}"
+ row.formatted_value = default_format
+ value_part = default_format
+
+ parts.append(row.separator + value_part)
+
+ concatenated_value = "".join(parts)
+
+ for attr in literal_props.attributes:
+ if attr.name == "Literal":
+ attr.string_value = concatenated_value
+ break
+
+ tool.Blender.update_viewport()
+ return {"FINISHED"}
+
+ def _update_formatted_value(self, new_element_key: str, old_formatted_value: str) -> str:
+ """
+ Update formatted value by replacing old element key placeholders with new one.
+ This preserves formatting functions like upper(), round(), etc.
+ """
+ import re
+
+ pattern = r'\{\{[^}]+\}\}'
+
+ new_base_value = f"{{{{{new_element_key}}}}}"
+ updated_value = re.sub(pattern, new_base_value, old_formatted_value)
+
+ return updated_value
+
+
+class ShowElementValuesInstructions(bpy.types.Operator):
+ bl_idname = "bim.show_element_values_instructions"
+ bl_label = "Element Values - Quick Start Guide"
+ bl_description = "Show general tips and formatting instructions for element values"
+ bl_options = {"REGISTER"}
+
+ def invoke(self, context, event):
+ return context.window_manager.invoke_props_dialog(self, width=700)
+
+ def draw(self, context):
+ layout = self.layout
+ layout.label(text="Element Values - Building Custom Literals", icon="INFO")
+
+ box = layout.box()
+ row = box.row()
+ row.label(text="Full Documentation:", icon="URL")
+ row.operator("wm.url_open", text="IFC Selector Syntax Guide", icon="URL").url = (
+ "https://docs.ifcopenshell.org/ifcopenshell-python/selector_syntax.html#getting-element-values"
+ )
+
+ box = layout.box()
+ box.label(text="WORKFLOW: BUILDING LITERALS WITH ROWS", icon="SEQUENCE")
+ col = box.column(align=True)
+ col.scale_y = 0.85
+ col.label(text="1. Select a category from the dropdown (Basic, Property Sets, etc.)")
+ col.label(text="2. Click 'Add Element' to create a new row")
+ col.label(text="3. Use the magnifying glass icon to browse available values for that category")
+ col.label(text="4. Optionally, click the format icon (star) to apply formatting (uppercase, round, etc.)")
+ col.label(text="5. Repeat to add more rows, each with its own separator text")
+ col.label(text="6. Click 'Apply to Literal' to concatenate all rows into the final text")
+
+ box = layout.box()
+ box.label(text="SEPARATORS & CUSTOM TEXT", icon="THREE_DOTS")
+ col = box.column(align=True)
+ col.scale_y = 0.85
+ col.label(text="• Each row has a separator field (shown before the value)")
+ col.label(text="• Default separator is ' - ' but you can change it to spaces, commas, newlines, etc.")
+ col.label(text="• Use 'Custom String' category to add plain text without any element key")
+ col.label(text="• Example: 'Custom String' row with 'Wall: ' → 'Custom String' with 'Type-' → 'Name' key")
+ col.label(text="• Result: 'Wall: Type-WALL-001' (combining custom text with element values)")
+
+ box = layout.box()
+ box.label(text="SELECTING SOURCE ELEMENT", icon="EYEDROPPER")
+ col = box.column(align=True)
+ col.scale_y = 0.85
+ col.label(text="• By default, uses the assigned product (if any) or the text annotation object itself")
+ col.label(text="• Use the eyedropper next to 'Element Values:' to select a different object")
+ col.label(text="• Useful for referencing values from related elements (like types, spaces, storeys)")
+ col.label(text="• The 'Source:' label shows which object is currently being used")
+
+ box = layout.box()
+ box.label(text="FORMATTING FUNCTIONS (click star icon on any row)", icon="SHADERFX")
+ col = box.column(align=True)
+ col.scale_y = 0.8
+
+ col.label(text="Text Case:")
+ col.label(text=" • Uppercase, Lowercase, Title Case")
+ col.label(text=" Example: upper({{Name}}) → 'WALL-001'")
+
+ col.separator(factor=0.5)
+ col.label(text="Numbers:")
+ col.label(text=" • Round - Round to precision (0.01, 0.1, 1, 10, etc.)")
+ col.label(text=" • Integer - Remove decimal part")
+ col.label(text=" • Number - Format with separators (1,234.56)")
+
+ col.separator(factor=0.5)
+ col.label(text="Lengths:")
+ col.label(text=" • Metric Length - Format as metric with units (45.50 m²)")
+ col.label(text=" • Imperial Length - Format as feet-inches (10'-6\")")
+
+ col.separator(factor=0.5)
+ col.label(text="Custom Expression:")
+ col.label(text=" • Write your own using functions: concat(), upper(), round(), etc.")
+ col.label(text=" • Use {{value}} as placeholder for the current row's value")
+
+ box = layout.box()
+ box.label(text="TIPS & ADVANCED USAGE", icon="LIGHTPROBE_VOLUME")
+ col = box.column(align=True)
+ col.scale_y = 0.8
+ col.label(text="• Category counts show available values: 'Property Sets (12)'")
+ col.label(text="• Regex patterns work in property set names: /Pset_.*Common/")
+ col.label(text="• Combine multiple formatted rows for complex labels")
+ col.label(text="• Each row remembers its own formatting when you re-edit it")
+
+ def execute(self, context):
+ return {"FINISHED"}
diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py
index 4dd025afc0..da04caf7ff 100644
--- a/src/bonsai/bonsai/bim/module/drawing/prop.py
+++ b/src/bonsai/bonsai/bim/module/drawing/prop.py
@@ -30,7 +30,7 @@ import bonsai.bim.module.drawing.annotation as annotation
import bonsai.bim.module.drawing.decoration as decoration
from mathutils import Matrix
from bonsai.bim.prop import BIMFilterGroup
-from bonsai.bim.module.drawing.data import DrawingsData, DecoratorData, SheetsData, AnnotationData
+from bonsai.bim.module.drawing.data import DrawingsData, DecoratorData, SheetsData, AnnotationData, ElementValuesData
from bonsai.bim.module.drawing.data import refresh as refresh_drawing_data
from pathlib import Path
from bonsai.bim.prop import Attribute, StrProperty
@@ -687,6 +687,105 @@ BOX_ALIGNMENT_POSITIONS = [
]
+class ElementValueRow(PropertyGroup):
+ """Represents a single element value row with category, key, and formatted value"""
+
+ category: EnumProperty(
+ name="Category",
+ items=[
+ ("Basic", "Basic", "Basic element information"),
+ ("Attributes", "Attributes", "IFC Attributes"),
+ ("Property Sets", "Property Sets", "Property Sets"),
+ ("Quantity Sets", "Quantity Sets", "Quantity Sets"),
+ ("Type", "Type", "Type information"),
+ ("Spatial", "Spatial", "Spatial relationships"),
+ ("Parent", "Parent", "Parent relationships"),
+ ("Classification", "Classification", "Classifications"),
+ ("Groups", "Groups", "Group assignments"),
+ ("Systems", "Systems", "System assignments"),
+ ("Zones", "Zones", "Zone assignments"),
+ ("Material", "Material", "Material information"),
+ ("Styles", "Styles", "Style information"),
+ ("Profiles", "Profiles", "Profile information"),
+ ("Coordinates", "Coordinates", "Coordinate information"),
+ ("Custom String", "Custom String", "Custom text (no element key)"),
+ ],
+ default="Basic",
+ )
+
+ element_key: StringProperty(
+ name="Element Key", description="The element value key (e.g., 'id', 'Name', 'Pset_WallCommon.Reference')", default=""
+ )
+
+ formatted_value: StringProperty(
+ name="Formatted Value",
+ description="The formatted value string with selector syntax (e.g., '{{id}}' or '``upper({{Name}})``')",
+ default="",
+ )
+
+ separator: StringProperty(
+ name="Separator",
+ description="Text to insert before this value when concatenating (e.g., ' - ', ', ', '\\n')",
+ default=" - ",
+ )
+
+ if TYPE_CHECKING:
+ category: str
+ element_key: str
+ formatted_value: str
+ separator: str
+
+
+def get_category_items_with_counts(self, context):
+ """Generate category items with counts dynamically"""
+ category_metadata = [
+ ("Basic", "Basic", "Basic element information", "OBJECT_DATA"),
+ ("Attributes", "Attributes", "IFC Attributes", "PROPERTIES"),
+ ("Property Sets", "Property Sets", "Property Sets", "ALIGN_JUSTIFY"),
+ ("Quantity Sets", "Quantity Sets", "Quantity Sets", "SNAP_VOLUME"),
+ ("Type", "Type", "Type information", "FILE_VOLUME"),
+ ("Spatial", "Spatial", "Spatial relationships", "HOME"),
+ ("Parent", "Parent", "Parent relationships", "FILE_PARENT"),
+ ("Classification", "Classification", "Classifications", "BOOKMARKS"),
+ ("Groups", "Groups", "Group assignments", "OUTLINER_COLLECTION"),
+ ("Systems", "Systems", "System assignments", "SYSTEM"),
+ ("Zones", "Zones", "Zone assignments", "MESH_CIRCLE"),
+ ("Material", "Material", "Material information", "MATERIAL"),
+ ("Styles", "Styles", "Style information", "COLOR"),
+ ("Profiles", "Profiles", "Profile information", "OUTLINER_DATA_CURVES"),
+ ("Coordinates", "Coordinates", "Coordinate information", "EMPTY_ARROWS"),
+ ("Custom String", "Custom String", "Add custom text (no element key)", "SMALL_CAPS"),
+ ]
+
+ obj = context.active_object
+
+ if obj and tool.Ifc.get_entity(obj):
+ try:
+ element = tool.Ifc.get_entity(obj)
+ text_element = element
+
+ if hasattr(self, 'product_used'):
+ if self.product_used:
+ element = tool.Ifc.get_entity(self.product_used)
+ else:
+ assigned = tool.Drawing.get_assigned_product(text_element)
+ if assigned:
+ element = assigned
+
+ available_keys = ElementValuesData.get_available_element_value_keys(element)
+ items = []
+ for i, (identifier, base_name, description, icon) in enumerate(category_metadata):
+ count = len(available_keys.get(identifier, []))
+ display_name = f"{base_name} ({count})" if count > 0 else base_name
+ items.append((identifier, display_name, description, icon, i))
+
+ return items
+ except Exception as e:
+ pass
+
+ return [(id, name, desc, icon, i) for i, (id, name, desc, icon) in enumerate(category_metadata)]
+
+
class LiteralProps(PropertyGroup):
def set_box_alignment(self, new_value):
markers = new_value.count(True)
@@ -725,11 +824,57 @@ class LiteralProps(PropertyGroup):
}
return text_data
+ show_element_values: bpy.props.BoolProperty(
+ name="Show Element Values", description="Show/hide the element values panel", default=False
+ )
+
+ expanded_category: bpy.props.StringProperty(
+ name="Expanded Category", description="Currently expanded category in the element values panel", default=""
+ )
+
+ element_values_filter: bpy.props.StringProperty(
+ name="Element Values Filter", description="Search filter for element values", default=""
+ )
+
+ product_used: PointerProperty(
+ name="Product Used",
+ type=bpy.types.Object,
+ description="Object to use for fetching element values. If empty, uses assigned product",
+ )
+
+ element_value_rows: CollectionProperty(
+ name="Element Value Rows",
+ type=ElementValueRow,
+ description="Collection of element value rows for building the literal value"
+ )
+
+ category_for_adding: EnumProperty(
+ name="Category for Adding",
+ items=get_category_items_with_counts,
+ default=0,
+ description="Category to use when adding a new element value row"
+ )
+
if TYPE_CHECKING:
attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
value: str
box_alignment: tuple[bool, bool, bool, bool, bool, bool, bool, bool, bool]
ifc_definition_id: int
+ element_value_rows: bpy.types.bpy_prop_collection_idprop[ElementValueRow]
+ category_for_adding: str
+
+
+class LiteralApplySettings(PropertyGroup):
+ literal_index: IntProperty(name="Literal Index")
+ apply_text_to_all: BoolProperty(name="Apply Text to All", default=False)
+ apply_path_to_all: BoolProperty(name="Apply Path to All", default=False)
+ apply_box_alignment_to_all: BoolProperty(name="Apply Box Alignment to All", default=False)
+
+ if TYPE_CHECKING:
+ literal_index: int
+ apply_text_to_all: bool
+ apply_path_to_all: bool
+ apply_box_alignment_to_all: bool
class BIMTextProperties(PropertyGroup):
@@ -763,6 +908,24 @@ class BIMTextProperties(PropertyGroup):
description="Non-default symbol to use for this text.",
)
+ apply_font_size_to_all: BoolProperty(
+ name="Apply Font Size to All", description="Apply font size changes to all selected text objects", default=False
+ )
+ apply_newline_to_all: BoolProperty(
+ name="Apply Newline to All", description="Apply newline changes to all selected text objects", default=False
+ )
+
+ literal_apply_settings: CollectionProperty(name="Literal Apply Settings", type=LiteralApplySettings)
+
+ def ensure_literal_apply_settings(self, literal_count: int):
+ """Ensure we have apply settings for all literals"""
+ while len(self.literal_apply_settings) > literal_count:
+ self.literal_apply_settings.remove(len(self.literal_apply_settings) - 1)
+
+ while len(self.literal_apply_settings) < literal_count:
+ setting = self.literal_apply_settings.add()
+ setting.literal_index = len(self.literal_apply_settings) - 1
+
if TYPE_CHECKING:
is_editing: bool
literals: bpy.types.bpy_prop_collection_idprop[LiteralProps]
@@ -772,6 +935,8 @@ class BIMTextProperties(PropertyGroup):
list_separator: str
symbol: Union[str, Literal["NO SYMBOL", "CUSTOM SYMBOL"]]
custom_symbol: str
+ apply_font_size_to_all: bool
+ apply_newline_to_all: bool
def get_symbol(self) -> Union[str, None]:
if self.symbol == "NO SYMBOL":
diff --git a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py
index 37d39e502f..2d8c4e60cf 100644
--- a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py
+++ b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py
@@ -42,6 +42,7 @@ from mathutils import geometry, Vector
from typing import Optional, Self, Union
from collections.abc import Callable, Sequence
from pathlib import Path
+from markdown_it import MarkdownIt
class External(svgwrite.container.Group):
@@ -61,6 +62,120 @@ class External(svgwrite.container.Group):
return self.xml
+def parse_markdown_it(text: str) -> list[dict[str, Union[str, None]]]:
+ """
+ Parse markdown (links, breaks, bold, italic, bullet points) from text and return structured information.
+ Returns a list of dicts with keys:
+ - 'text': the text content
+ - 'url': link target if present, else None
+ - 'break': True if this is a line break (\n or
), else False
+ - 'bold': True if bold, else False
+ - 'italic': True if italic, else False
+ - \u2022 for bullet points (you start a bullet point list by prefixing \n before the first - bullet point)
+
+ Example:
+ Hello **bold** *italic* [World](http://ex.com)\n
+ - Bullet 1\n
+ - Bullet 2
Another [Link](http://foo.com)
+ [
+ {'text': 'Hello ', 'url': None, 'break': False, 'bold': False, 'italic': False},
+ {'text': 'bold', 'url': None, 'break': False, 'bold': True, 'italic': False},
+ {'text': ' ', 'url': None, 'break': False, 'bold': False, 'italic': False},
+ {'text': 'italic', 'url': None, 'break': False, 'bold': False, 'italic': True},
+ {'text': ' ', 'url': None, 'break': False, 'bold': False, 'italic': False},
+ {'text': 'World', 'url': 'http://ex.com', 'break': False, 'bold': False, 'italic': False},
+ {'text': None, 'url': None, 'break': True, 'bold': False, 'italic': False},
+ {'text': '\u2022 ', 'url': None, 'break': True, 'bold': False, 'italic': False},
+ {'text': 'Bullet 1', 'url': None, 'break': False, 'bold': False, 'italic': False},
+ {'text': None, 'url': None, 'break': True, 'bold': False, 'italic': False},
+ {'text': '\u2022 ', 'url': None, 'break': True, 'bold': False, 'italic': False},
+ {'text': 'Bullet 2 ', 'url': None, 'break': False, 'bold': False, 'italic': False},
+ {'text': None, 'url': None, 'break': True, 'bold': False, 'italic': False},
+ {'text': 'Another ', 'url': None, 'break': False, 'bold': False, 'italic': False},
+ {'text': 'Link', 'url': 'http://foo.com', 'break': False, 'bold': False, 'italic': False}
+ ]
+ """
+ md = MarkdownIt("commonmark")
+ tokens = md.parse(text)
+ segments = []
+ bold = False
+ italic = False
+ link_opening = None
+ link_text = None
+ i = 0
+ while i < len(tokens):
+ token = tokens[i]
+ if token.type == "bullet_list_open":
+ i += 1
+ while i < len(tokens) and tokens[i].type != "bullet_list_close":
+ if tokens[i].type == "list_item_open":
+ segments.append({"text": "\u2022 ", "url": None, "break": True, "bold": False, "italic": False})
+ j = i + 1
+ while j < len(tokens) and tokens[j].type != "list_item_close":
+ if tokens[j].type == "inline":
+ for child in tokens[j].children or []:
+ if child.type == "softbreak":
+ segments.append({"text": None, "url": None, "break": True, "bold": False, "italic": False})
+ elif child.type == "html_inline" and child.content.strip().lower() == "
":
+ segments.append({"text": None, "url": None, "break": True, "bold": False, "italic": False})
+ elif child.type == "strong_open":
+ bold = True
+ elif child.type == "strong_close":
+ bold = False
+ elif child.type == "em_open":
+ italic = True
+ elif child.type == "em_close":
+ italic = False
+ elif child.type == "link_open":
+ link_opening = child
+ elif child.type == "text" and link_opening:
+ link_text = child.content
+ elif child.type == "link_close" and link_opening:
+ url = link_opening.attrGet("href")
+ if url and link_text:
+ segments.append({"text": link_text, "url": url, "break": False, "bold": bold, "italic": italic})
+ link_opening = None
+ link_text = None
+ elif child.type == "text" and not link_opening:
+ segments.append({"text": child.content, "url": None, "break": False, "bold": bold, "italic": italic})
+ j += 1
+ i = j
+ else:
+ i += 1
+ i += 1
+ elif token.type == "inline":
+ for child in token.children or []:
+ if child.type == "softbreak":
+ segments.append({"text": None, "url": None, "break": True, "bold": False, "italic": False})
+ elif child.type == "html_inline" and child.content.strip().lower() == "
":
+ segments.append({"text": None, "url": None, "break": True, "bold": False, "italic": False})
+ elif child.type == "strong_open":
+ bold = True
+ elif child.type == "strong_close":
+ bold = False
+ elif child.type == "em_open":
+ italic = True
+ elif child.type == "em_close":
+ italic = False
+ elif child.type == "link_open":
+ link_opening = child
+ elif child.type == "text" and link_opening:
+ link_text = child.content
+ elif child.type == "link_close" and link_opening:
+ url = link_opening.attrGet("href")
+ if url and link_text:
+ segments.append({"text": link_text, "url": url, "break": False, "bold": bold, "italic": italic})
+ link_opening = None
+ link_text = None
+ elif child.type == "text" and not link_opening:
+ segments.append({"text": child.content, "url": None, "break": False, "bold": bold, "italic": italic})
+ i += 1
+ segments = [seg for seg in segments if seg.get("text") is not None or seg.get("break", False)]
+ if not segments:
+ return [{"text": text, "url": None, "break": False, "bold": False, "italic": False}]
+ return segments
+
+
class SvgWriter:
metadata: list[str]
resource_paths: dict[tool.Drawing.ResourceType, Union[str, None]]
@@ -893,19 +1008,78 @@ class SvgWriter:
text = tool.Drawing.replace_text_literal_variables(
text_literal.Literal, product or element, reverse_list, list_separator
)
- text_tags = self.create_text_tag(
- text,
- text_position_svg,
- angle,
- text_literal.BoxAlignment,
- classes_str,
- fill_bg=fill_bg,
- line_number_start=line_number,
- newline_at=newline_at,
- )
- for tag in text_tags:
- self.svg.add(tag)
- line_number += len(tag.elements)
+
+ text_segments = parse_markdown_it(text)
+
+ if len(text_segments) == 1 and text_segments[0]["url"] is None and not text_segments[0].get("break", False):
+ text_tags = self.create_text_tag(
+ text,
+ text_position_svg,
+ angle,
+ text_literal.BoxAlignment,
+ classes_str,
+ fill_bg=fill_bg,
+ line_number_start=line_number,
+ newline_at=newline_at,
+ )
+ for tag in text_tags:
+ self.svg.add(tag)
+ line_number += len(text_tags)
+ else:
+ base_text_attrs = SvgWriter.get_box_alignment_parameters(text_literal.BoxAlignment)
+ text_position_svg_str = ", ".join(map(str, text_position_svg))
+ text_transform = f"translate({text_position_svg_str}) rotate({angle})"
+
+ text_tag = self.svg.text("", transform=text_transform, class_=classes_str, **base_text_attrs)
+
+ line_idx = 0
+ new_line = True
+ bullet_next = False
+ for idx, segment in enumerate(text_segments):
+ if segment.get("break", False):
+ if segment.get("text") == "\u2022 ":
+ bullet_next = True
+ line_idx += 1
+ new_line = True
+ continue
+ if segment["text"] is None:
+ continue
+ text_content = segment["text"]
+ if bullet_next:
+ text_content = "\u2022 " + (text_content or "")
+ bullet_next = False
+
+ if new_line:
+ dy, x, y = f"{line_idx}em", 0, 0
+ new_line = False
+ else:
+ dy, x, y = None, None, None
+
+ tspan = self.svg.tspan(text_content, class_=classes_str)
+ if segment.get("bold", False):
+ tspan.attribs["font-weight"] = "bold"
+ if segment.get("italic", False):
+ tspan.attribs["font-style"] = "italic"
+ if dy is not None:
+ tspan.attribs["dy"] = dy
+ if x is not None:
+ tspan.attribs["x"] = x
+ if y is not None:
+ tspan.attribs["y"] = y
+
+ if segment["url"]:
+ link_element = self.svg.a(href=segment["url"], target="_blank")
+ link_element.add(tspan)
+ text_tag.add(link_element)
+ else:
+ text_tag.add(tspan)
+
+ if fill_bg:
+ fill_bg_tag = self.add_fill_bg(text_tag)
+ self.svg.add(fill_bg_tag)
+
+ self.svg.add(text_tag)
+ line_number += 1
def draw_empty_annotation(self, obj: bpy.types.Object, classes: list[str]) -> None:
x_offset = self.raw_width / 2
diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py
index 1a25b387dd..552ca7f9b4 100644
--- a/src/bonsai/bonsai/bim/module/drawing/ui.py
+++ b/src/bonsai/bonsai/bim/module/drawing/ui.py
@@ -28,8 +28,9 @@ from bonsai.bim.module.drawing.data import (
DrawingsData,
ElementFiltersData,
DecoratorData,
+ ElementValuesData,
)
-from typing import TYPE_CHECKING, Union
+from typing import TYPE_CHECKING, Union, Optional
if TYPE_CHECKING:
from bonsai.bim.module.drawing.prop import DocProperties, Drawing, Sheet
@@ -475,21 +476,20 @@ class BIM_PT_sheets(Panel):
op = row3.operator("bim.activate_drawing_from_sheet", icon="OUTLINER_OB_CAMERA", text="")
+
if active_sheet.reference_type == "DRAWING":
drawingnamesvg = active_sheet.name
drawingname = drawingnamesvg.split(".svg")[0]
ifc_file = tool.Ifc.get()
+ ifc_annotations = ifc_file.by_type("IfcAnnotation")
drawingid = None
-
- for annotation in ifc_file.by_type("IfcAnnotation"):
+ for annotation in ifc_annotations:
if annotation.ObjectType != "DRAWING":
continue
-
Annotation_Name = annotation.Name.replace(",", "") # Remove commas
if Annotation_Name == drawingname:
drawingid = annotation.id()
break
-
if drawingid is not None:
op.drawing = drawingid
@@ -551,6 +551,61 @@ class BIM_PT_product_assignments(Panel):
col.enabled = bool(ProductAssignmentsData.data["relating_product"])
+def get_category_icon(category_name):
+ """Get appropriate icon for each category"""
+ icons = {
+ "Basic": "OBJECT_DATA",
+ "Attributes": "PROPERTIES",
+ "Property Sets": "PROPERTIES",
+ "Quantity Sets": "SNAP_VOLUME",
+ "Type": "OUTLINER_OB_MESH",
+ "Spatial": "HOME",
+ "Parent": "FILE_PARENT",
+ "Classification": "BOOKMARKS",
+ "Groups": "GROUP",
+ "Systems": "SYSTEM",
+ "Zones": "MESH_CIRCLE",
+ "Material": "MATERIAL",
+ "Profiles": "MESH_DATA",
+ "Coordinates": "EMPTY_ARROWS",
+ }
+ return icons.get(category_name, "DOT")
+
+
+def get_current_product_for_element_values(obj: bpy.types.Object, literal_props) -> Optional[bpy.types.Object]:
+ """Get the product to use for fetching element values - either explicitly set or assigned product"""
+ if hasattr(literal_props, "product_used") and literal_props.product_used:
+ return literal_props.product_used
+
+ element = tool.Ifc.get_entity(obj)
+ if element:
+ return tool.Drawing.get_assigned_product(element) or obj
+ return obj
+
+
+def get_category_icon(category: str) -> str:
+ """Get the icon for an element value category"""
+ icons = {
+ "Basic": "OBJECT_DATA",
+ "Attributes": "PROPERTIES",
+ "Property Sets": "ALIGN_JUSTIFY",
+ "Quantity Sets": "SNAP_VOLUME",
+ "Type": "FILE_VOLUME",
+ "Spatial": "HOME",
+ "Parent": "FILE_PARENT",
+ "Classification": "BOOKMARKS",
+ "Groups": "OUTLINER_COLLECTION",
+ "Systems": "SYSTEM",
+ "Zones": "MESH_CIRCLE",
+ "Material": "MATERIAL",
+ "Styles": "COLOR",
+ "Profiles": "OUTLINER_DATA_CURVES",
+ "Coordinates": "EMPTY_ARROWS",
+ "Custom String": "SMALL_CAPS",
+ }
+ return icons.get(category, "DOT")
+
+
class BIM_PT_text(Panel):
bl_label = "Text"
bl_idname = "BIM_PT_text"
@@ -563,10 +618,10 @@ class BIM_PT_text(Panel):
@classmethod
def poll(cls, context):
if not tool.Ifc.get() or not context.active_object:
- return
+ return False
element = tool.Ifc.get_entity(context.active_object)
if not element:
- return
+ return False
return tool.Drawing.is_annotation_object_type(element, ["TEXT", "TEXT_LEADER"])
def draw_text_editing_ui(
@@ -592,6 +647,8 @@ class BIM_PT_text(Panel):
row = self.layout.row(align=True)
row.prop(props, "font_size")
+ row.prop(props, "apply_font_size_to_all", text="", icon="COPYDOWN")
+
row = self.layout.row(align=True)
row.prop(props, "newline_at")
row = self.layout.row(align=True)
@@ -604,10 +661,13 @@ class BIM_PT_text(Panel):
if props.symbol == "CUSTOM SYMBOL":
row = self.layout.row(align=True)
row.prop(props, "custom_symbol", text="")
+ row.prop(props, "apply_newline_to_all", text="", icon="COPYDOWN")
+ select_op = row.operator("bim.select_similar_text_literal_value", text="", icon="RESTRICT_SELECT_OFF")
+ select_op.literal_value = str(props.newline_at)
+ select_op.attribute_type = "newline"
for i, literal_props in enumerate(props.literals):
box = self.layout.box()
- row = self.layout.row(align=True)
row = box.row(align=True)
row.label(text=f"Literal[{i}]:")
@@ -617,25 +677,140 @@ class BIM_PT_text(Panel):
row.operator("bim.order_text_literal_down", icon="TRIA_DOWN", text="").literal_prop_id = i
row.operator("bim.remove_text_literal", icon="X", text="").literal_prop_id = i
- # skip BoxAlignment since we're going to format it ourselves
- attributes = [a for a in literal_props.attributes if a.name != "BoxAlignment"]
- popup_active_attribute = attributes[0] if popup_mode else None
- bonsai.bim.helper.draw_attributes(attributes, box, popup_active_attribute=popup_active_attribute)
+ if len(literal_props.attributes) > 0 and i < len(props.literal_apply_settings):
+ row = box.row(align=True)
+ row.prop(literal_props.attributes[0], "string_value", text="Literal")
+
+ expand_icon = "DOWNARROW_HLT" if getattr(literal_props, "show_element_values", False) else "RIGHTARROW"
+ op = row.operator("bim.toggle_element_values_panel", icon=expand_icon, text="")
+ op.literal_prop_id = i
+
+ row.prop(props.literal_apply_settings[i], "apply_text_to_all", text="", icon="COPYDOWN")
+
+ element = tool.Ifc.get_entity(obj)
+ assigned_element = tool.Drawing.get_assigned_product(element) or element
+ resolved_value = tool.Drawing.replace_text_literal_variables(
+ literal_props.attributes[0].string_value,
+ assigned_element,
+ props.reverse_list,
+ props.list_separator,
+ )
+ row = box.row(align=True)
+ row.label(text="CurrentValue:")
+ row.label(text=str(resolved_value))
+
+ # Show the element values panel if expanded
+ if getattr(literal_props, "show_element_values", False):
+ values_box = box.box()
+
+ help_row = values_box.row(align=True)
+ help_row.operator("bim.show_element_values_instructions", text="Instructions", icon="QUESTION")
+
+ element_values_row = values_box.row(align=True)
+ element_values_row.label(text="Element Values:", icon="PROPERTIES")
+ element_values_row.prop(literal_props, "product_used", text="", icon="EYEDROPPER")
+
+ current_product = get_current_product_for_element_values(obj, literal_props)
+
+ product_name = current_product.name if (current_product and hasattr(current_product, "name")) else "Unknown"
+ source_row = values_box.row()
+ source_row.label(text=f"Source: {product_name}", icon="OBJECT_DATA")
+
+ element = tool.Ifc.get_entity(current_product) if current_product else None
+ if element:
+ add_row = values_box.row(align=True)
+ add_row.prop(literal_props, "category_for_adding", text="")
+
+ op = add_row.operator("bim.add_element_value_row", text="Add Element", icon="ADD")
+ op.literal_prop_id = i
+
+ if len(literal_props.element_value_rows) > 0:
+ for row_idx, value_row in enumerate(literal_props.element_value_rows):
+ row = values_box.row(align=True)
+
+ is_custom_string = value_row.category == "Custom String"
+
+ if is_custom_string:
+ category_icon = get_category_icon(value_row.category)
+ row.prop(value_row, "element_key", text="", icon=category_icon)
+ else:
+ split = row.split(factor=0.25, align=True)
+
+ sep_col = split.row(align=True)
+ sep_col.prop(value_row, "separator", text="")
+
+ key_col = split.row(align=True)
+ category_icon = get_category_icon(value_row.category)
+ key_col.prop(value_row, "element_key", text="", icon=category_icon)
+
+ op = row.operator("bim.element_value_suggestions_popup", text="", icon="VIEWZOOM")
+ op.literal_prop_id = i
+ op.row_index = row_idx
+ op.category = value_row.category
+
+ op = row.operator("bim.format_element_value_row", text="", icon="SHADERFX")
+ op.literal_prop_id = i
+ op.row_index = row_idx
+
+ op = row.operator("bim.remove_element_value_row", text="", icon="X")
+ op.literal_prop_id = i
+ op.row_index = row_idx
+
+ apply_row = values_box.row()
+ apply_row.scale_y = 1.2
+ op = apply_row.operator("bim.apply_element_value_rows_to_literal", text="Apply to Literal", icon="CHECKMARK")
+ op.literal_prop_id = i
+ else:
+ error_row = values_box.row()
+ error_row.label(text="Selected object has no IFC data", icon="ERROR")
+
+ if len(literal_props.attributes) > 1:
+ attr = literal_props.attributes[1]
+ row = box.row(align=True)
+ if getattr(attr, "data_type", None) == "enum" and getattr(attr, "enum_items", None):
+ row.prop(attr, "enum_value", text="Path")
+ select_value = attr.enum_value
+ else:
+ row.prop(attr, "string_value", text="Path")
+ select_value = attr.string_value
+ if i < len(props.literal_apply_settings):
+ row.prop(props.literal_apply_settings[i], "apply_path_to_all", text="", icon="COPYDOWN")
+
+ other_attributes = [a for a in literal_props.attributes[2:] if a.name != "BoxAlignment"]
+ if other_attributes:
+ bonsai.bim.helper.draw_attributes(other_attributes, box)
row = box.row(align=True)
- cols = [row.column(align=True) for i in range(3)]
- for i in range(9):
- cols[i % 3].prop(
+ cols = [row.column(align=True) for j in range(3)]
+ for j in range(9):
+ cols[j % 3].prop(
literal_props,
"box_alignment",
text="",
- index=i,
- icon="RADIOBUT_ON" if literal_props.box_alignment[i] else "RADIOBUT_OFF",
+ index=j,
+ icon="RADIOBUT_ON" if literal_props.box_alignment[j] else "RADIOBUT_OFF",
)
col = row.column(align=True)
- col.label(text=" Text box alignment:")
- col.label(text=f' {literal_props.attributes["BoxAlignment"].string_value}')
+ alignment_label_row = col.row(align=True)
+ alignment_label_row.label(text=" Text box alignment:")
+ if i < len(props.literal_apply_settings):
+ alignment_label_row.prop(
+ props.literal_apply_settings[i], "apply_box_alignment_to_all", text="", icon="COPYDOWN"
+ )
+
+ box_alignment_value = (
+ literal_props.attributes[
+ next(
+ (idx for idx, attr in enumerate(literal_props.attributes) if attr.name == "BoxAlignment"),
+ -1,
+ )
+ ].string_value
+ if any(attr.name == "BoxAlignment" for attr in literal_props.attributes)
+ else "N/A"
+ )
+
+ col.label(text=f" {box_alignment_value}")
def draw(self, context):
obj = context.active_object
@@ -652,10 +827,21 @@ class BIM_PT_text(Panel):
row = self.layout.row(align=True)
row.label(text="FontSize")
- row.label(text=str(text_data["FontSize"]))
+ click_op = row.operator(
+ "bim.select_similar_text_literal_value", text=str(text_data["FontSize"]), emboss=False
+ )
+ click_op.literal_value = str(text_data["FontSize"])
+ click_op.attribute_type = "font_size"
+ click_op.display_text = str(text_data["FontSize"])
+
row = self.layout.row(align=True)
row.label(text="Newline_At")
- row.label(text=str(text_data["Newline_At"]))
+ click_op = row.operator(
+ "bim.select_similar_text_literal_value", text=str(text_data["Newline_At"]), emboss=False
+ )
+ click_op.literal_value = str(text_data["Newline_At"])
+ click_op.attribute_type = "newline"
+ click_op.display_text = str(text_data["Newline_At"])
row = self.layout.row(align=True)
row.label(text="Reverse_List")
row.label(text=str(text_data["Reverse_List"]))
@@ -663,12 +849,30 @@ class BIM_PT_text(Panel):
row.label(text="List_Separator")
row.label(text=str(text_data["List_Separator"]))
- for literal_data in text_data["Literals"]:
+ for i, literal_data in enumerate(text_data["Literals"]):
box = self.layout.box()
+ box.label(text=f"Literal[{i}]:")
+
+ # Combine both approaches: clickable attributes from PR #7292 and display from PR #7106
for attribute in literal_data:
row = box.row(align=True)
row.label(text=attribute)
- row.label(text=literal_data[attribute])
+ click_op = row.operator(
+ "bim.select_similar_text_literal_value",
+ text=str(literal_data[attribute]),
+ emboss=False,
+ )
+ click_op.literal_value = str(literal_data[attribute])
+ click_op.literal_index = i
+ if attribute == "Literal":
+ click_op.attribute_type = "literal"
+ elif attribute == "Path":
+ click_op.attribute_type = "path"
+ elif attribute == "BoxAlignment":
+ click_op.attribute_type = "box_alignment"
+ else:
+ click_op.attribute_type = "text"
+ click_op.display_text = str(literal_data[attribute])
class BIM_UL_drawinglist(bpy.types.UIList):
diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py
index b938514c70..ae593d0873 100644
--- a/src/bonsai/bonsai/bim/module/drawing/workspace.py
+++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py
@@ -259,6 +259,9 @@ class AnnotationToolUI:
add_layout_hotkey_operator(
cls.layout, "Readjust", "S_G", "Readjust tags based on the products they are assigned to"
)
+ row = cls.layout.row(align=True)
+ props = tool.Drawing.get_document_props()
+ row.operator("bim.filter_selected_objects_if_intersected_by_camera", text="Filter by Camera")
class Hotkey(bpy.types.Operator, tool.Ifc.Operator):
diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py
index f9c7f57782..d4ed4f8a9b 100644
--- a/src/bonsai/bonsai/bim/prop.py
+++ b/src/bonsai/bonsai/bim/prop.py
@@ -92,7 +92,12 @@ def get_attribute_enum_values(prop: "Attribute", context: bpy.types.Context) ->
# Support weird buildingSMART dictionary mappings which behave like enums
items: list[tuple[str, str, str]] = []
- data = json.loads(prop.enum_items)
+ if not prop.enum_items:
+ return items
+ try:
+ data = json.loads(prop.enum_items)
+ except Exception:
+ return items
if isinstance(data, dict):
for k, v in data.items():
@@ -633,7 +638,7 @@ class BIMProperties(PropertyGroup):
],
name="Time Unit",
default="HOUR",
- )
+ )
tab_visibilities: CollectionProperty(type=BIMTabVisibility, name="Tab Visibilities")
panel_properties: CollectionProperty(type=BIMPanelProperties, name="Panel Properties")
@@ -774,15 +779,6 @@ class BIMFacet(PropertyGroup):
pset: StringProperty(name="Pset")
value: StringProperty(name="Value")
type: StringProperty(name="Type")
- filter_mode: EnumProperty(
- name="Filter Mode",
- items=[
- ("ADD", "Add", "Add elements to the result set (query entire IFC file)", "ADD", 0),
- ("SUBTRACT", "Subtract", "Subtract matching elements from previous results", "REMOVE", 1),
- ("FILTER", "Filter", "Filter down previous results to matching elements", "FILTER", 2),
- ],
- default="ADD",
- )
comparison: EnumProperty(
items=[
("=", "equal to", ""),
@@ -800,7 +796,6 @@ class BIMFacet(PropertyGroup):
pset: str
value: str
type: str
- filter_mode: Literal["ADD", "SUBTRACT", "FILTER"]
comparison: Literal["=", "!=", ">=", "<=", ">", "<", "*=", "!*="]
diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py
index 1508e36e8f..e14b82c47d 100644
--- a/src/ifcopenshell-python/ifcopenshell/util/selector.py
+++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py
@@ -136,21 +136,32 @@ get_element_grammar = lark.Lark(
)
format_grammar = lark.Lark(
- """start: function
+ """start: expression
- function: round | number | int | format_length | lower | upper | title | concat | substr | ESCAPED_STRING | NUMBER
+ ?expression: add_sub
+ ?add_sub: mul_div
+ | add_sub "+" mul_div -> add
+ | add_sub "-" mul_div -> subtract
+ ?mul_div: function
+ | mul_div "*" function -> multiply
+ | mul_div "/" function -> divide
+
+ function: round | number | int | format_length | lower | upper | title | concat | substr | variable | ESCAPED_STRING | NUMBER | "(" expression ")"
- round: "round(" function "," NUMBER ")"
- number: "number(" function ["," ESCAPED_STRING ["," ESCAPED_STRING]] ")"
- int: "int(" function ")"
+ variable: "{{" query_path "}}"
+ query_path: /[^}]+/
+
+ round: "round(" expression "," NUMBER ")"
+ number: "number(" expression ["," ESCAPED_STRING ["," ESCAPED_STRING]] ")"
+ int: "int(" expression ")"
format_length: metric_length | imperial_length
- metric_length: "metric_length(" function "," NUMBER "," NUMBER ")"
- imperial_length: "imperial_length(" function "," NUMBER ["," ESCAPED_STRING "," ESCAPED_STRING ["," boolean]] ")"
- lower: "lower(" function ")"
- upper: "upper(" function ")"
- title: "title(" function ")"
- concat: "concat(" function ("," function)* ")"
- substr: "substr(" function "," SIGNED_INT ["," SIGNED_INT] ")"
+ metric_length: "metric_length(" expression "," NUMBER "," NUMBER ")"
+ imperial_length: "imperial_length(" expression "," NUMBER ["," ESCAPED_STRING "," ESCAPED_STRING ["," boolean]] ")"
+ lower: "lower(" expression ")"
+ upper: "upper(" expression ")"
+ title: "title(" expression ")"
+ concat: "concat(" expression ("," expression)* ")"
+ substr: "substr(" expression "," SIGNED_INT ["," SIGNED_INT] ")"
boolean: TRUE | FALSE
TRUE: "true" | "True" | "TRUE"
@@ -186,9 +197,83 @@ format_grammar = lark.Lark(
class FormatTransformer(lark.Transformer):
+ def __init__(self, element=None):
+ """Initialize transformer with optional element for variable substitution"""
+ super().__init__()
+ self.element = element
+
def start(self, args):
return args[0]
+ def expression(self, args):
+ return args[0]
+
+ def variable(self, args):
+ """Handle variable substitution like {{z}} or {{Pset_Wall.FireRating}}"""
+ if self.element is None:
+ return "0" # Default value if no element context
+
+ query_path = args[0]
+ try:
+ value = get_element_value(self.element, query_path)
+ if value is None:
+ return "0"
+ # Convert to string for further processing
+ return str(value)
+ except:
+ return "0" # Return default on error
+
+ def query_path(self, args):
+ """Extract the query path from variable"""
+ return str(args[0]).strip()
+
+ def add(self, args):
+ """Handle addition operation"""
+ left, right = args
+ try:
+ left_val = float(left) if left != "None" and left is not None else 0.0
+ right_val = float(right) if right != "None" and right is not None else 0.0
+ result = left_val + right_val
+ # Return integer if result has no decimal part
+ if result % 1 == 0:
+ return str(int(result))
+ return str(result)
+ except (ValueError, TypeError):
+ # If can't convert to numbers, concatenate as strings
+ return str(left) + str(right)
+
+ def subtract(self, args):
+ """Handle subtraction operation"""
+ left, right = args
+ left_val = float(left) if left != "None" and left is not None else 0.0
+ right_val = float(right) if right != "None" and right is not None else 0.0
+ result = left_val - right_val
+ if result % 1 == 0:
+ return str(int(result))
+ return str(result)
+
+ def multiply(self, args):
+ """Handle multiplication operation"""
+ left, right = args
+ left_val = float(left) if left != "None" and left is not None else 0.0
+ right_val = float(right) if right != "None" and right is not None else 0.0
+ result = left_val * right_val
+ if result % 1 == 0:
+ return str(int(result))
+ return str(result)
+
+ def divide(self, args):
+ """Handle division operation"""
+ left, right = args
+ left_val = float(left) if left != "None" and left is not None else 0.0
+ right_val = float(right) if right != "None" and right is not None else 1.0
+ if right_val == 0:
+ return "inf" # or raise an error, or return "0"
+ result = left_val / right_val
+ if result % 1 == 0:
+ return str(int(result))
+ return str(result)
+
def function(self, args):
return args[0]
@@ -208,7 +293,7 @@ class FormatTransformer(lark.Transformer):
return str(args[0]).title()
def concat(self, args):
- return "".join(args)
+ return "".join(str(arg) for arg in args)
def substr(self, args):
if len(args) == 3:
@@ -238,13 +323,14 @@ class FormatTransformer(lark.Transformer):
return str(result)
def number(self, args):
- if isinstance(args[0], str):
- args[0] = float(args[0]) if "." in args[0] else int(args[0])
+ arg_val = args[0]
+ if isinstance(arg_val, str):
+ arg_val = float(arg_val) if "." in arg_val else int(arg_val)
if len(args) >= 3 and args[2]:
- return "{:,}".format(args[0]).replace(".", "*").replace(",", args[2]).replace("*", args[1])
+ return "{:,}".format(arg_val).replace(".", "*").replace(",", args[2]).replace("*", args[1])
elif len(args) >= 2 and args[1]:
- return "{}".format(args[0]).replace(".", args[1])
- return "{:,}".format(args[0])
+ return "{}".format(arg_val).replace(".", args[1])
+ return "{:,}".format(arg_val)
def format_length(self, args):
return args[0]
@@ -284,7 +370,8 @@ class FormatTransformer(lark.Transformer):
)
def int(self, args: list[str]) -> str:
- return str(int(float(args[0])))
+ value = 0.0 if args[0] == "None" else args[0] or 0.0
+ return str(int(float(value)))
class GetElementTransformer(lark.Transformer):
@@ -310,8 +397,18 @@ class GetElementTransformer(lark.Transformer):
return args[1:-1].replace("\\", "")
-def format(query: str) -> str:
- return FormatTransformer().transform(format_grammar.parse(query))
+def format(query: str, element: Optional[ifcopenshell.entity_instance] = None) -> str:
+ """Format a query string with optional element context for variable substitution.
+
+ :param query: Format query string (can include {{variable}} placeholders)
+ :param element: Optional IFC element for variable substitution
+ :return: Formatted string
+
+ Example:
+ format("{{z}} / 2", element) # Substitutes element's z value
+ format("imperial_length({{z}} / 2, 4)", element) # Uses z in calculation
+ """
+ return FormatTransformer(element).transform(format_grammar.parse(query))
def get_element_value(element: ifcopenshell.entity_instance, query: str) -> Any:
@@ -1160,4 +1257,4 @@ class FacetTransformer(lark.Transformer):
if comparison.startswith("!"):
return not result
- return result
+ return result
\ No newline at end of file