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"