Compare commits

...

3 Commits

Author SHA1 Message Date
Petru Conduraru 9a02b308fd Append aggregates with their parts, sub-aggregates and openings (#5909)
bim.append_library_element_by_query only ever brought in the top-level
element of an assembly. Two layers needed fixing:

ifcopenshell.api.project.append_asset never whitelisted IsDecomposedBy
for IfcElement, so IfcRelAggregates was not walked, and even then the
is_another_asset guard filtered aggregated children because they share
the IfcProduct target class with the top asset. IfcRelAggregates is now
treated as a dependent relationship, the same way IfcRelVoidsElement and
IfcRelProjectsElement already are (precedent: 55e97fd170). Scoped to
IfcElement so spatial decomposition is untouched.

The Bonsai AppendLibraryElement operator then only created a viewport
object for the single top element, leaving appended parts invisible in
the 3D view. It now creates objects, materials, styles and types for
every part and sub-assembly via get_decomposition, filtering openings
the same way normal import does.

This change was written with AI assistance.
2026-07-21 12:27:16 +03:00
Ryan Schultz e52e5e2e58 Bonsai: add category-level select-all to the Drawings list (#8826)
Add an "Is Selected" checkbox to each target-view category header in
BIM_UL_drawinglist that toggles selection for all drawings in the
category. The toggle only affects drawings currently visible in the
list (honoring the show_drawings_on_sheets_only filter), and the header
checkbox reflects the aggregate selection state of its drawings.

Also make category headers more obvious: wrap them in a box() for a
distinct inset background and make the header name clickable to
expand/contract the category (same as the disclosure triangle).

Ref: #8825

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:39:22 -05:00
Ryan Schultz 2d59ea1988 Bonsai: add toggle to show only drawings placed on sheets (#8824)
Adds a "Show Only Drawings on Sheets" toggle below the drawing list. When
enabled, the list is filtered to drawings referenced by at least one sheet
(target-view headers with no sheeted drawings are hidden too), and
bim.select_all_drawings only acts on the visible/filtered drawings.

A drawing is considered sheeted when its drawing document Location matches a
document reference Location on any SHEET-scoped IfcDocumentInformation.
Filtering is computed live so it reflects sheet edits without reloading.

Closes #8823

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:03:56 -05:00
8 changed files with 191 additions and 26 deletions
@@ -108,6 +108,7 @@ classes = (
operator.SelectAssignedProduct,
operator.SelectSimilarTextLiteralValue,
operator.ToggleTargetView,
operator.ToggleDrawingCategorySelection,
operator.OpenDocumentationWebUi,
operator.FilterSelectedObjectsIfIntersectedByCamera,
prop.Variable,
@@ -2310,7 +2310,11 @@ class SelectAllDrawings(bpy.types.Operator):
def execute(self, context):
props = tool.Drawing.get_document_props()
# When filtering to sheeted drawings only, act on the visible drawings only.
sheeted_ids = tool.Drawing.get_sheeted_drawing_ids() if props.show_drawings_on_sheets_only else None
for drawing in props.drawings:
if sheeted_ids is not None and drawing.is_drawing and drawing.ifc_definition_id not in sheeted_ids:
continue
if drawing.is_selected != self.select_all:
drawing.is_selected = self.select_all
return {"FINISHED"}
@@ -3870,6 +3874,26 @@ class ToggleTargetView(bpy.types.Operator):
return {"FINISHED"}
class ToggleDrawingCategorySelection(bpy.types.Operator):
bl_idname = "bim.toggle_drawing_category_selection"
bl_label = "Toggle Category Selection"
bl_description = "Select or deselect all drawings in this view category"
bl_options = {"REGISTER", "UNDO"}
target_view: bpy.props.StringProperty()
if TYPE_CHECKING:
target_view: str
def execute(self, context):
drawings = tool.Drawing.get_visible_drawings_in_category(self.target_view)
# If everything visible in the category is already selected, deselect all; otherwise select all.
new_state = not all(d.is_selected for d in drawings)
for drawing in drawings:
drawing.is_selected = new_state
return {"FINISHED"}
class ExpandSheet(bpy.types.Operator):
bl_idname = "bim.expand_sheet"
bl_label = "Expand Sheet"
@@ -409,6 +409,12 @@ class DocProperties(PropertyGroup):
options=set(),
)
is_editing_drawings: BoolProperty(name="Is Editing Drawings", default=False)
show_drawings_on_sheets_only: BoolProperty(
name="Show Only Drawings on Sheets",
description="Only show drawings that are placed on a sheet",
default=False,
options=set(),
)
is_editing_schedules: BoolProperty(name="Is Editing Schedules", default=False)
is_editing_references: BoolProperty(name="Is Editing References", default=False)
target_view: EnumProperty(
@@ -439,6 +445,7 @@ class DocProperties(PropertyGroup):
should_use_annotation_cache: bool
should_draw_linked_projects: bool
is_editing_drawings: bool
show_drawings_on_sheets_only: bool
is_editing_schedules: bool
is_editing_references: bool
target_view: Literal["PLAN_VIEW", "ELEVATION_VIEW", "SECTION_VIEW", "REFLECTED_PLAN_VIEW", "MODEL_VIEW"]
+54 -2
View File
@@ -341,6 +341,7 @@ class BIM_PT_drawings(Panel):
self.layout.template_list(
"BIM_UL_drawinglist", "", self.props, "drawings", self.props, "active_drawing_index"
)
self.layout.prop(self.props, "show_drawings_on_sheets_only")
class BIM_PT_schedules(Panel):
@@ -873,8 +874,8 @@ class BIM_UL_drawinglist(bpy.types.UIList):
layout.label(text="", translate=False)
return
row = layout.row(align=True)
if item.is_drawing:
row = layout.row(align=True)
row.label(text="", icon="BLANK1")
selected_icon = "CHECKBOX_HLT" if item.is_selected else "CHECKBOX_DEHLT"
row.prop(item, "is_selected", text="", icon=selected_icon, emboss=False)
@@ -895,6 +896,9 @@ class BIM_UL_drawinglist(bpy.types.UIList):
item.ifc_definition_id
)
else:
# Give category headers a distinct inset background so they stand out from drawing rows.
box = layout.box()
row = box.row(align=True)
if item.target_view == "PLAN_VIEW":
icon = "UV_FACESEL"
elif item.target_view == "ELEVATION_VIEW":
@@ -915,7 +919,55 @@ class BIM_UL_drawinglist(bpy.types.UIList):
op = row.operator("bim.toggle_target_view", text="", emboss=False, icon="DISCLOSURE_TRI_RIGHT")
op.target_view = item.target_view
op.option = "EXPAND"
row.prop(item, "name", text="", icon=icon, emboss=False)
group = tool.Drawing.get_visible_drawings_in_category(item.target_view)
all_selected = bool(group) and all(d.is_selected for d in group)
row.operator(
"bim.toggle_drawing_category_selection",
text="",
icon="CHECKBOX_HLT" if all_selected else "CHECKBOX_DEHLT",
emboss=False,
).target_view = item.target_view
row.separator(factor=0.5, type="SPACE")
# Clicking the header name toggles expand/contract, same as the disclosure triangle.
op = row.operator("bim.toggle_target_view", text=item.name, icon=icon, emboss=False)
op.target_view = item.target_view
op.option = "CONTRACT" if item.is_expanded else "EXPAND"
def filter_items(self, context, data: DocProperties, propname: str):
drawings = getattr(data, propname)
helper_funcs = bpy.types.UI_UL_list
flt_flags = []
flt_neworder = []
if self.filter_name:
flt_flags = helper_funcs.filter_items_by_name(
self.filter_name,
self.bitflag_filter_item,
drawings,
"name",
reverse=self.use_filter_sort_reverse,
)
if not flt_flags:
flt_flags = [self.bitflag_filter_item] * len(drawings)
props = tool.Drawing.get_document_props()
if props.show_drawings_on_sheets_only:
ifc_file = tool.Ifc.get()
sheeted_ids = tool.Drawing.get_sheeted_drawing_ids()
# Target view headers are only shown if they contain a sheeted drawing.
sheeted_target_views = {
tool.Drawing.get_drawing_target_view(ifc_file.by_id(drawing_id)) for drawing_id in sheeted_ids
}
for i, item in enumerate(drawings):
if item.is_drawing:
is_visible = item.ifc_definition_id in sheeted_ids
else:
is_visible = item.target_view in sheeted_target_views
if not is_visible:
flt_flags[i] &= ~self.bitflag_filter_item
return flt_flags, flt_neworder
class BIM_UL_sheets(bpy.types.UIList):
@@ -637,10 +637,12 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
elif element.is_a("IfcProduct"):
# NOTE: Non-types are not exposed in UI directly
# but the code is still used when appending products by query.
self.import_product_from_ifc(element, context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type is not None and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context)
elements = self.get_appended_elements(element)
self.import_product_from_ifc(elements, context)
for appended_element in elements:
element_type = ifcopenshell.util.element.get_type(appended_element)
if element_type is not None and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context)
elif element.is_a("IfcMaterial"):
self.import_material_from_ifc(element, context)
elif element.is_a("IfcSurfaceStyle"):
@@ -676,17 +678,32 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
ifc_importer.file = self.file
ifc_importer.create_style(style)
def import_product_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None:
def get_appended_elements(self, element: ifcopenshell.entity_instance) -> set[ifcopenshell.entity_instance]:
"""``element`` plus any parts brought in with it (e.g. the parts of an appended assembly)."""
elements = {element}
for part in ifcopenshell.util.element.get_decomposition(element):
if not part.is_a("IfcFeatureElement") or part.is_a("IfcSurfaceFeature"):
elements.add(part)
return elements
def import_product_from_ifc(
self,
elements: Union[ifcopenshell.entity_instance, set[ifcopenshell.entity_instance]],
context: bpy.types.Context,
) -> None:
self.file = tool.Ifc.get()
if isinstance(elements, ifcopenshell.entity_instance):
elements = {elements}
logger = logging.getLogger("ImportIFC")
ifc_import_settings = import_ifc.IfcImportSettings.factory(context, IfcStore.path, logger)
ifc_importer = import_ifc.IfcImporter(ifc_import_settings)
ifc_importer.file = self.file
ifc_importer.process_context_filter()
ifc_importer.material_creator.load_existing_materials()
self.import_materials(element, ifc_importer)
self.import_styles(element, ifc_importer)
ifc_importer.create_generic_elements({element})
for element in elements:
self.import_materials(element, ifc_importer)
self.import_styles(element, ifc_importer)
ifc_importer.create_generic_elements(elements)
ifc_importer.place_objects_in_collections()
def import_type_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None:
@@ -2772,10 +2789,12 @@ class AppendInspectedLinkedElement(AppendLibraryElement):
library=linked_ifc_file,
element=element_to_append,
)
self.import_product_from_ifc(element, context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context)
elements = self.get_appended_elements(element)
self.import_product_from_ifc(elements, context)
for appended_element in elements:
element_type = ifcopenshell.util.element.get_type(appended_element)
if element_type and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context)
return {"FINISHED"}
+44
View File
@@ -2887,6 +2887,50 @@ class Drawing(bonsai.core.tool.Drawing):
break
return sheet_references
@classmethod
def get_sheeted_drawing_ids(cls) -> set[int]:
"""Get the IFC ids of all drawings that are placed on at least one sheet."""
ifc_file = tool.Ifc.get()
sheet_locations: set[Union[str, None]] = set()
for sheet in ifc_file.by_type("IfcDocumentInformation"):
if sheet.Scope != "SHEET":
continue
for reference in cls.get_document_references(sheet):
sheet_locations.add(reference.Location)
if not sheet_locations:
return set()
result: set[int] = set()
for drawing in ifc_file.by_type("IfcAnnotation"):
if drawing.ObjectType != "DRAWING":
continue
drawing_document = cls.get_drawing_document(drawing)
if drawing_document and drawing_document.Location in sheet_locations:
result.add(drawing.id())
return result
@classmethod
def get_visible_drawings_in_category(cls, target_view: str) -> list[DrawingProperties]:
"""Get the drawing items in a target view category that are currently visible in the drawing list.
Grouping is positional: individual drawing items don't carry their own ``target_view``, they belong to
the most recent header item above them. Only expanded categories contribute drawing items to the
collection, so a collapsed category yields an empty list. Respects the ``show_drawings_on_sheets_only``
filter so that select-all only affects visible drawings.
"""
props = cls.get_document_props()
drawings: list[DrawingProperties] = []
in_category = False
for item in props.drawings:
if not item.is_drawing:
# Header row: we're inside the requested category until the next header.
in_category = item.target_view == target_view
elif in_category:
drawings.append(item)
if props.show_drawings_on_sheets_only:
sheeted_ids = cls.get_sheeted_drawing_ids()
drawings = [d for d in drawings if d.ifc_definition_id in sheeted_ids]
return drawings
@classmethod
def get_camera_matrix(cls, camera: bpy.types.Object) -> Matrix:
matrix_world = camera.matrix_world.copy().normalized()
@@ -397,7 +397,7 @@ class Usecase:
self.whitelisted_inverse_attributes = {
"IfcObjectDefinition": ["HasAssociations"],
"IfcObject": ["IsDefinedBy.IfcRelDefinesByProperties"],
"IfcElement": ["HasOpenings"],
"IfcElement": ["HasOpenings", "IsDecomposedBy"],
"IfcDistributionElement": ["IsNestedBy"],
self.base_material_class: ["HasExternalReferences", "HasProperties", "HasRepresentation"],
"IfcRepresentationItem": [
@@ -529,24 +529,18 @@ class Usecase:
new = self.file.create_entity(element.is_a())
self.reuse_identities[element_identity] = new
# Void, projection, and aggregation relationships are "dependent" and always considered.
is_dependent_rel = element.is_a() in ("IfcRelVoidsElement", "IfcRelProjectsElement", "IfcRelAggregates")
for i, attribute in enumerate(element):
new_attribute = None
if isinstance(attribute, ifcopenshell.entity_instance):
# Void and projection relationships are special because they
# are "dependent" relationships, so we always consider them.
# We do _not_ whitelist (i.e. in is_another_asset)
# IfcFeatureElement because you can have things like
# IfcRelAssociatesClassification to openings! We only ever want
# to consider IfcFeatureElements in IfcRelVoidsElements and
# IfcRelProjectsElements.
if element.is_a() in ("IfcRelVoidsElement", "IfcRelProjectsElement") or not self.is_another_asset(
attribute
):
if is_dependent_rel or not self.is_another_asset(attribute):
new_attribute = self.add_element(attribute)
elif isinstance(attribute, tuple) and attribute and isinstance(attribute[0], ifcopenshell.entity_instance):
new_attribute = []
for item in attribute:
if self.is_another_asset(item):
if not is_dependent_rel and self.is_another_asset(item):
continue
if skip_not_reused_entities_attr_i is not None and i == skip_not_reused_entities_attr_i:
identity = item.wrapped_data.identity()
@@ -18,6 +18,7 @@
import numpy as np
import ifcopenshell.api.aggregate
import ifcopenshell.api.classification
import ifcopenshell.api.context
import ifcopenshell.api.cost
@@ -409,6 +410,29 @@ class TestAppendAssetIFC2X3(test.bootstrap.IFC2X3):
ifcopenshell.api.project.append_asset(self.file, library=library, element=element)
assert self.file.by_type("IfcWall")[0].HasOpenings[0].RelatedOpeningElement.is_a("IfcOpeningElement")
def test_append_an_aggregate_with_its_parts_and_sub_aggregates_and_openings(self):
library = ifcopenshell.api.project.create_file(version=self.file.schema)
top = ifcopenshell.api.root.create_entity(library, ifc_class="IfcElementAssembly", name="House_Module")
sub_assembly = ifcopenshell.api.root.create_entity(
library, ifc_class="IfcElementAssembly", name="Wall_Panel_SubAssembly"
)
part = ifcopenshell.api.root.create_entity(library, ifc_class="IfcWall", name="Panel Wall")
opening = ifcopenshell.api.root.create_entity(library, ifc_class="IfcOpeningElement")
ifcopenshell.api.feature.add_feature(library, feature=opening, element=part)
ifcopenshell.api.aggregate.assign_object(library, relating_object=sub_assembly, products=[part])
ifcopenshell.api.aggregate.assign_object(library, relating_object=top, products=[sub_assembly])
appended = ifcopenshell.api.project.append_asset(self.file, library=library, element=top)
assert len(self.file.by_type("IfcElementAssembly")) == 2
assert len(self.file.by_type("IfcWall")) == 1
assert len(self.file.by_type("IfcOpeningElement")) == 1
appended_sub = appended.IsDecomposedBy[0].RelatedObjects[0]
assert appended_sub.is_a("IfcElementAssembly")
appended_part = appended_sub.IsDecomposedBy[0].RelatedObjects[0]
assert appended_part.is_a("IfcWall")
assert appended_part.HasOpenings[0].RelatedOpeningElement.is_a("IfcOpeningElement")
def test_append_a_product_with_unrelated_relationships_to_openings(self):
library = ifcopenshell.api.project.create_file(version=self.file.schema)
ifcopenshell.api.root.create_entity(library, ifc_class="IfcProject")