mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user