PR7500 squashed commit

This commit is contained in:
falken10vdl
2025-12-19 13:51:01 +01:00
parent eba4405310
commit 85aeb6e1cc
10 changed files with 3030 additions and 143 deletions
@@ -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; }
@@ -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():
+813 -7
View File
@@ -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
File diff suppressed because it is too large Load Diff
+166 -1
View File
@@ -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":
+187 -13
View File
@@ -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 <br>), 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 <br>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() == "<br>":
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() == "<br>":
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
+227 -23
View File
@@ -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):
@@ -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):
+7 -12
View File
@@ -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["=", "!=", ">=", "<=", ">", "<", "*=", "!*="]
@@ -139,21 +139,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"
@@ -189,9 +200,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]
@@ -211,7 +296,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:
@@ -241,13 +326,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]
@@ -287,7 +373,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):
@@ -313,8 +400,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:
@@ -1163,4 +1260,4 @@ class FacetTransformer(lark.Transformer):
if comparison.startswith("!"):
return not result
return result
return result