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