From de389bdce3b01c6cfca26bcab97a5fd63b0c8ea9 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 20 Jul 2026 11:34:33 +0300 Subject: [PATCH] Bonsai: per element drawing appearance via assignable CSS classes Drawings are styled with CSS. Every element already gets a generated class list (IFC class, material, predefined type, drawing metadata keys), and annotations could additionally carry hand written classes in EPset_Annotation.Classes, read by SvgWriter. Regular building elements had no such channel, so there was no way to make one slab dash and another fill grey without hand editing the property set and the stylesheet. CreateDrawing.get_svg_classes now reads the same EPset_Annotation.Classes property, so custom classes reach cut, projection, surface and space geometry too. SvgWriter reuses the shared tool.Drawing helper instead of its own lookup. A Drawing Classes panel on the object tab assigns and unassigns those classes across the selection, offering the line weight, line style and fill classes that default.css ships, plus free text for stylesheet authors. default.css gains matching element level rules, declared last so they override the class, material and predefined type rules above them. No new rendering path is introduced. The classes land in the same class attribute and are resolved by the same stylesheet, so existing custom CSS keeps working and can select on the new classes. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/data/assets/default.css | 17 +++++++ .../bonsai/bim/module/drawing/__init__.py | 4 ++ src/bonsai/bonsai/bim/module/drawing/data.py | 27 ++++++++++ .../bonsai/bim/module/drawing/operator.py | 50 ++++++++++++++++++ .../bonsai/bim/module/drawing/svgwriter.py | 4 +- src/bonsai/bonsai/bim/module/drawing/ui.py | 51 +++++++++++++++++++ src/bonsai/bonsai/core/drawing.py | 8 +++ src/bonsai/bonsai/core/tool.py | 7 ++- src/bonsai/bonsai/tool/drawing.py | 40 +++++++++++++++ src/bonsai/test/core/test_drawing.py | 12 +++++ src/bonsai/test/tool/test_drawing.py | 46 +++++++++++++++++ 11 files changed, 261 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/data/assets/default.css b/src/bonsai/bonsai/bim/data/assets/default.css index d82d97e8f2..7872bce52f 100644 --- a/src/bonsai/bonsai/bim/data/assets/default.css +++ b/src/bonsai/bonsai/bim/data/assets/default.css @@ -101,3 +101,20 @@ text.GRID, tspan.GRID { /* 5mm */ font-size: 8.25px; } .PredefinedType-STEEL { fill: url(#steel); stroke: black; stroke-width: 0.5; } .PredefinedType-CONCRETE { fill: url(#concrete); stroke: black; stroke-width: 0.5; } .PredefinedType-PLASTERBOARD { fill: url(#sand); stroke: black; stroke-width: 0.25; } +/* Per element classes, assigned via EPset_Annotation.Classes. Declared last so + they override the class, material and predefined type rules above. */ +.fine { stroke-width: 0.18; } +.thin { stroke-width: 0.25; } +.medium { stroke-width: 0.35; } +.thick { stroke-width: 0.5; } +.strong { stroke-width: 1; } +.dashed { stroke-dasharray: 3, 2; } +.dotted { stroke-dasharray: 0.5, 1.5; } +.dashdot { stroke-dasharray: 6, 2, 1, 2; } +.hidden { stroke: none; fill: none; } +.fill-none { fill: none; } +.fill-white { fill: #ffffff; } +.fill-light { fill: #dddddd; } +.fill-grey { fill: #aaaaaa; } +.fill-dark { fill: #777777; } +.fill-solid { fill: #000000; } diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 12b63d9372..d76cb15627 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -33,6 +33,7 @@ classes = ( operator.AddAnnotation, operator.AddAnnotationType, operator.AddDrawing, + operator.AddElementDrawingClass, operator.AddDrawingStyle, operator.AddDrawingToSheet, operator.AddReference, @@ -76,6 +77,7 @@ classes = ( operator.SelectElementValues, operator.InsertFormattedLiteralPopup, operator.AddElementValueRow, + operator.RemoveElementDrawingClass, operator.RemoveElementValueRow, operator.ElementValueSuggestionsPopup, operator.FormatElementValueRow, @@ -125,12 +127,14 @@ classes = ( ui.BIM_PT_sheets, ui.BIM_PT_drawings, ui.BIM_PT_camera, + ui.BIM_PT_element_drawing_classes, ui.BIM_PT_element_filters, ui.BIM_PT_drawing_underlay, ui.BIM_PT_schedules, ui.BIM_PT_references, ui.BIM_PT_product_assignments, ui.BIM_PT_text, + ui.BIM_MT_element_drawing_classes, ui.BIM_UL_drawinglist, ui.BIM_UL_sheets, # Core gizmos (shared across modules) diff --git a/src/bonsai/bonsai/bim/module/drawing/data.py b/src/bonsai/bonsai/bim/module/drawing/data.py index 23ee7c1987..8a1a212a6c 100644 --- a/src/bonsai/bonsai/bim/module/drawing/data.py +++ b/src/bonsai/bonsai/bim/module/drawing/data.py @@ -39,6 +39,7 @@ def refresh(): ElementFiltersData.is_loaded = False AnnotationData.is_loaded = False DecoratorData.is_loaded = False + ElementClassesData.is_loaded = False class ProductAssignmentsData: @@ -231,6 +232,32 @@ FONT_SIZES = { "title": 7.0, } +# CSS classes shipped in default.css that may be assigned per element. +# Custom stylesheets are free to define their own, so this is only a +# convenience list for the UI, never a restriction. +ELEMENT_CLASSES = { + "Line Weight": ("fine", "thin", "medium", "thick", "strong"), + "Line Style": ("dashed", "dotted", "dashdot", "hidden"), + "Fill": ("fill-none", "fill-white", "fill-light", "fill-grey", "fill-dark", "fill-solid"), +} + + +class ElementClassesData: + data = {} + is_loaded = False + + @classmethod + def load(cls): + cls.data = {"classes": cls.classes()} + cls.is_loaded = True + + @classmethod + def classes(cls) -> list[str]: + obj = bpy.context.active_object + if not obj or not (element := tool.Ifc.get_entity(obj)): + return [] + return tool.Drawing.get_element_classes(element) + class DecoratorData: # stores 1 type of data per object diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 35d93db7d2..6a596c6e93 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1480,6 +1480,9 @@ class CreateDrawing(bpy.types.Operator): tool.Drawing.canonicalise_class_name(key) + "-" + tool.Drawing.canonicalise_class_name(str(value)) ) + # ─── Custom ──────────────────────────────────────────────── + classes.extend(tool.Drawing.get_element_classes(element)) + return classes def is_manifold(self, obj) -> bool: @@ -3647,6 +3650,53 @@ class DisableEditingAssignedProduct(bpy.types.Operator, tool.Ifc.Operator): core.disable_editing_assigned_product(tool.Drawing, obj=context.active_object) +class AddElementDrawingClass(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_element_drawing_class" + bl_label = "Add Drawing Class" + bl_description = ( + "Assign a CSS class to this element so it can be styled individually in drawings.\n" + "Applies to all selected IFC objects" + ) + bl_options = {"REGISTER", "UNDO"} + + name: bpy.props.StringProperty(name="Class", default="") + + if TYPE_CHECKING: + name: str + + def invoke(self, context, event): + if self.name: + return self.execute(context) + return context.window_manager.invoke_props_dialog(self) + + def _execute(self, context): + name = tool.Drawing.sanitise_class_name(self.name) + if not name: + self.report({"ERROR"}, "A valid CSS class name is required.") + return {"CANCELLED"} + for obj in tool.Blender.get_selected_objects(): + if element := tool.Ifc.get_entity(obj): + core.add_element_class(tool.Drawing, element=element, name=name) + self.name = "" + + +class RemoveElementDrawingClass(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.remove_element_drawing_class" + bl_label = "Remove Drawing Class" + bl_description = "Unassign this CSS class from all selected IFC objects" + bl_options = {"REGISTER", "UNDO"} + + name: bpy.props.StringProperty(name="Class") + + if TYPE_CHECKING: + name: str + + def _execute(self, context): + for obj in tool.Blender.get_selected_objects(): + if element := tool.Ifc.get_entity(obj): + core.remove_element_class(tool.Drawing, element=element, name=self.name) + + class LoadSheets(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.load_sheets" bl_label = "Load Sheets" diff --git a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py index e6cb797738..475ace9266 100644 --- a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py +++ b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py @@ -537,9 +537,7 @@ class SvgWriter: str(ifcopenshell.util.element.get_predefined_type(element)) ) classes = [global_id, element.is_a(), predefined_type] - custom_classes: str = ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Classes") - if custom_classes: - classes.extend(custom_classes.split()) + classes.extend(tool.Drawing.get_element_classes(element)) for key in self.metadata: value = ifcopenshell.util.selector.get_element_value(element, key) if value: diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 5c690e7282..684d88835b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -26,9 +26,11 @@ from bpy.types import Panel import bonsai.bim.helper import bonsai.tool as tool from bonsai.bim.module.drawing.data import ( + ELEMENT_CLASSES, DecoratorData, DocumentsData, DrawingsData, + ElementClassesData, ElementFiltersData, ProductAssignmentsData, SheetsData, @@ -571,6 +573,55 @@ class BIM_PT_product_assignments(Panel): col.enabled = bool(ProductAssignmentsData.data["relating_product"]) +class BIM_MT_element_drawing_classes(bpy.types.Menu): + bl_label = "Add Drawing Class" + bl_idname = "BIM_MT_element_drawing_classes" + + def draw(self, context): + assert self.layout + for category, names in ELEMENT_CLASSES.items(): + self.layout.label(text=category) + for name in names: + self.layout.operator("bim.add_element_drawing_class", text=name).name = name + self.layout.separator() + self.layout.operator("bim.add_element_drawing_class", text="Custom Class...", icon="ADD").name = "" + + +class BIM_PT_element_drawing_classes(Panel): + bl_label = "Drawing Classes" + bl_idname = "BIM_PT_element_drawing_classes" + bl_options = {"DEFAULT_CLOSED"} + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "object" + bl_order = 2 + bl_parent_id = "BIM_PT_tab_object_metadata" + + @classmethod + def poll(cls, context): + if not tool.Ifc.get() or not context.active_object: + return False + return bool(tool.Ifc.get_entity(context.active_object)) + + def draw(self, context): + if not ElementClassesData.is_loaded: + ElementClassesData.load() + + assert self.layout + row = self.layout.row(align=True) + row.menu("BIM_MT_element_drawing_classes", icon="ADD", text="Add Class") + + classes = ElementClassesData.data["classes"] + if not classes: + self.layout.label(text="No Classes Assigned", icon="BRUSH_DATA") + return + + for name in classes: + row = self.layout.row(align=True) + row.label(text=name, icon="BRUSH_DATA") + row.operator("bim.remove_element_drawing_class", icon="X", text="").name = name + + def get_category_icon(category_name): """Get appropriate icon for each category""" icons = { diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 2a1f760570..ab2b47e0e4 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -637,3 +637,11 @@ def activate_drawing_view( blender.activate_camera(camera) drawing_tool.isolate_camera_collection(camera) drawing_tool.activate_drawing(camera) + + +def add_element_class(drawing: type[tool.Drawing], element: ifcopenshell.entity_instance, name: str) -> None: + drawing.add_element_class(element, name) + + +def remove_element_class(drawing: type[tool.Drawing], element: ifcopenshell.entity_instance, name: str) -> None: + drawing.remove_element_class(element, name) diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 51120ad088..8db541c650 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -323,6 +323,7 @@ class Document: @interface class Drawing: def activate_drawing(cls, camera): pass + def add_element_class(cls, element, name): pass def add_literal(cls, **attributes): pass def clear_annotation_relationships(cls, drawing): pass def copy_representation(cls, source, dest): pass @@ -386,6 +387,7 @@ class Drawing: def get_drawing_group(cls, drawing): pass def get_drawing_references(cls, drawing): pass def get_drawing_target_view(cls, drawing): pass + def get_element_classes(cls, element): pass def get_group_drawing(cls, group): pass def get_group_elements(cls, group): pass def get_ifc_representation_class(cls, object_type): pass @@ -414,20 +416,21 @@ class Drawing: def open_spreadsheet(cls, uri): pass def open_svg(cls, filepath): pass def reload_representation(cls, obj, representation): pass + def remove_element_class(cls, element, name): pass def run_drawing_activate_model(cls): pass def run_root_assign_class(cls, obj=None, ifc_class=None, predefined_type=None, should_add_representation=True, context=None, ifc_representation_class=None): pass def run_type_assign_type(cls, element=None, relating_type=None): pass + def sanitise_class_name(cls, name): pass def select_assigned_product(cls, drawing): pass def set_camera_name(cls, drawing, name): pass def set_drawing_collection_name(cls, drawing, collection): pass + def set_element_classes(cls, element, classes): pass def set_name(cls, element, name): pass def setup_annotation_object(cls, obj, object_type): pass def setup_shading_styles_path(cls, resource_path): pass def show_decorations(cls): pass def sync_object_placement(cls, obj): pass def update_embedded_svg_location(cls, uri, old_location, new_location): pass - - @interface class Duplicate: def get_decomposition_relationships(cls, objs): pass diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index aedc1219d9..bdf751f971 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -2329,6 +2329,46 @@ class Drawing(bonsai.core.tool.Drawing): metadata_str = pset_data.get("Metadata", "") or "" return [v_ for v in metadata_str.split(",") if (v_ := v.strip())] + @classmethod + def get_element_classes(cls, element: ifcopenshell.entity_instance) -> list[str]: + classes = ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Classes") + if not isinstance(classes, str): + return [] + return classes.split() + + @classmethod + def set_element_classes(cls, element: ifcopenshell.entity_instance, classes: list[str]) -> None: + ifc_file = tool.Ifc.get() + pset = tool.Pset.get_element_pset(element, "EPset_Annotation") + if not pset: + if not classes: + return + pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="EPset_Annotation") + ifcopenshell.api.pset.edit_pset(ifc_file, pset=pset, properties={"Classes": " ".join(classes)}) + + @classmethod + def add_element_class(cls, element: ifcopenshell.entity_instance, name: str) -> None: + name = cls.sanitise_class_name(name) + if not name: + return + classes = cls.get_element_classes(element) + if name in classes: + return + cls.set_element_classes(element, classes + [name]) + + @classmethod + def remove_element_class(cls, element: ifcopenshell.entity_instance, name: str) -> None: + classes = cls.get_element_classes(element) + if name not in classes: + return + cls.set_element_classes(element, [c for c in classes if c != name]) + + @classmethod + def sanitise_class_name(cls, name: str) -> str: + """Strip characters that cannot appear in a CSS class name.""" + name = re.sub(r"[^0-9a-zA-Z_-]+", "-", name.strip()).strip("-") + return re.sub(r"^[0-9-]+", "", name) + @classmethod def get_annotation_z_index(cls, drawing: ifcopenshell.entity_instance) -> float: return ifcopenshell.util.element.get_pset(drawing, "EPset_Annotation", "ZIndex") or 0 diff --git a/src/bonsai/test/core/test_drawing.py b/src/bonsai/test/core/test_drawing.py index 914ddbbb54..ef57cec2b8 100644 --- a/src/bonsai/test/core/test_drawing.py +++ b/src/bonsai/test/core/test_drawing.py @@ -663,3 +663,15 @@ class TestAddAnnotation: relating_type="element_type", enable_editing=True, ) + + +class TestAddElementClass: + def test_run(self, drawing): + drawing.add_element_class("element", "dashed").should_be_called() + subject.add_element_class(drawing, element="element", name="dashed") + + +class TestRemoveElementClass: + def test_run(self, drawing): + drawing.remove_element_class("element", "dashed").should_be_called() + subject.remove_element_class(drawing, element="element", name="dashed") diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index 580b326822..8143731ccc 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -1126,3 +1126,49 @@ class TestIsDrawingActive(NewFile): # addresses, so this assertion documents that assumption. assert bpy.app.background is True assert subject.is_drawing_active() is True + + +class TestElementClasses(NewFile): + def test_no_classes_by_default(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab") + assert subject.get_element_classes(element) == [] + + def test_add_and_remove_classes(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab") + + subject.add_element_class(element, "dashed") + subject.add_element_class(element, "fill-grey") + assert subject.get_element_classes(element) == ["dashed", "fill-grey"] + assert ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Classes") == "dashed fill-grey" + + # Adding a duplicate is a no-op. + subject.add_element_class(element, "dashed") + assert subject.get_element_classes(element) == ["dashed", "fill-grey"] + + subject.remove_element_class(element, "dashed") + assert subject.get_element_classes(element) == ["fill-grey"] + + # Removing an unassigned class is a no-op. + subject.remove_element_class(element, "dashed") + assert subject.get_element_classes(element) == ["fill-grey"] + + def test_classes_are_sanitised(self): + assert subject.sanitise_class_name(" fill grey ") == "fill-grey" + assert subject.sanitise_class_name("fill-grey") == "fill-grey" + assert subject.sanitise_class_name("Wall_1") == "Wall_1" + assert subject.sanitise_class_name("2thick") == "thick" + assert subject.sanitise_class_name("!!!") == "" + + def test_preserves_existing_annotation_pset(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcAnnotation") + pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="EPset_Annotation") + ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties={"Classes": "small", "Symbol": "dot"}) + subject.add_element_class(element, "dashed") + assert subject.get_element_classes(element) == ["small", "dashed"] + assert ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Symbol") == "dot"