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.
This commit is contained in:
Petru Conduraru
2026-07-20 11:34:33 +03:00
parent 55a2430d71
commit de389bdce3
11 changed files with 261 additions and 5 deletions
@@ -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; }
@@ -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)
@@ -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
@@ -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"
@@ -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:
@@ -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 = {
+8
View File
@@ -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)
+5 -2
View File
@@ -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
+40
View File
@@ -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
+12
View File
@@ -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")
+46
View File
@@ -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"