Skip to content

Commit 2d1fd7f

Browse files
author
bonsaiPR automation
committed
Merge remote-tracking branch 'pr-8928/feat-4779-material-merge' into Frankenstein_build-0.9.0-alpha2610050136
2 parents 2944cb3 + 790be3e commit 2d1fd7f

9 files changed

Lines changed: 394 additions & 1 deletion

File tree

‎src/bonsai/bonsai/bim/module/material/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
operator.DisableEditingMaterialSetItem,
3838
operator.DisableEditingMaterialSetItemProfile,
3939
operator.DisableEditingMaterials,
40+
operator.DisableMaterialMerge,
4041
operator.DuplicateLayer,
4142
operator.EditAssignedMaterial,
4243
operator.EditMaterial,
@@ -48,8 +49,10 @@
4849
operator.EnableEditingMaterialSetItem,
4950
operator.EnableEditingMaterialSetItemProfile,
5051
operator.EnableEditingMaterialStyle,
52+
operator.EnableMaterialMerge,
5153
operator.ExpandMaterialCategory,
5254
operator.LoadMaterials,
55+
operator.MergeMaterials,
5356
operator.RemoveConstituent,
5457
operator.RemoveLayer,
5558
operator.RemoveListItem,

‎src/bonsai/bonsai/bim/module/material/operator.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,64 @@ def _execute(self, context):
361361
return {"CANCELLED"}
362362

363363

364+
class EnableMaterialMerge(bpy.types.Operator):
365+
bl_idname = "bim.enable_material_merge"
366+
bl_label = "Merge Materials"
367+
bl_description = "Pick two or more redundant materials to merge into the active material"
368+
bl_options = {"REGISTER", "UNDO"}
369+
370+
def execute(self, context):
371+
props = tool.Material.get_material_props()
372+
props.is_merging_materials = True
373+
for material in props.materials:
374+
material.is_selected = False
375+
return {"FINISHED"}
376+
377+
378+
class DisableMaterialMerge(bpy.types.Operator):
379+
bl_idname = "bim.disable_material_merge"
380+
bl_label = "Cancel Merging Materials"
381+
bl_options = {"REGISTER", "UNDO"}
382+
383+
def execute(self, context):
384+
props = tool.Material.get_material_props()
385+
props.is_merging_materials = False
386+
for material in props.materials:
387+
material.is_selected = False
388+
return {"FINISHED"}
389+
390+
391+
class MergeMaterials(bpy.types.Operator, tool.Ifc.Operator):
392+
bl_idname = "bim.merge_materials"
393+
bl_label = "Merge Selected Into Active"
394+
bl_description = (
395+
"Merge the checked materials into the active material.\n"
396+
"Every product, type, layer, profile, constituent, and material list that used "
397+
"one of the checked materials is reassigned to the active material.\n"
398+
"The now-redundant materials, along with their own properties and styles, are removed."
399+
)
400+
bl_options = {"REGISTER", "UNDO"}
401+
402+
def _execute(self, context):
403+
ifc_file = tool.Ifc.get()
404+
props = tool.Material.get_material_props()
405+
active_material = props.active_material
406+
if not active_material or active_material.is_category:
407+
self.report({"ERROR"}, "Select the material to merge into first.")
408+
return {"CANCELLED"}
409+
target = ifc_file.by_id(active_material.ifc_definition_id)
410+
sources = [
411+
ifc_file.by_id(m.ifc_definition_id)
412+
for m in props.materials
413+
if m.is_selected and not m.is_category and m.ifc_definition_id != target.id()
414+
]
415+
if not sources:
416+
self.report({"ERROR"}, "No materials were checked to merge.")
417+
return {"CANCELLED"}
418+
core.merge_materials(tool.Ifc, tool.Material, materials=sources, merge_into=target)
419+
props.is_merging_materials = False
420+
421+
364422
class RemoveMaterialSet(bpy.types.Operator, tool.Ifc.Operator):
365423
bl_idname = "bim.remove_material_set"
366424
bl_label = "Remove Material Set"

‎src/bonsai/bonsai/bim/module/material/prop.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ class Material(PropertyGroup):
153153
is_expanded: BoolProperty(name="Is Expanded", default=False)
154154
has_style: BoolProperty(name="Has Style", default=True)
155155
total_elements: IntProperty(name="Total Elements")
156+
is_selected: BoolProperty(name="Is Selected", description="Selected as a source material to merge", default=False)
156157

157158
if TYPE_CHECKING:
158159
name: str
@@ -161,6 +162,7 @@ class Material(PropertyGroup):
161162
is_expanded: bool
162163
has_style: bool
163164
total_elements: int
165+
is_selected: bool
164166

165167

166168
class BIMMaterialProperties(PropertyGroup):
@@ -181,6 +183,7 @@ def update_active_material_index(self, context: bpy.types.Context) -> None:
181183
editing_material_type: StringProperty(name="Editing Material Type")
182184
styles: EnumProperty(items=get_styles, name="Styles")
183185
contexts: EnumProperty(items=get_contexts, name="Contexts")
186+
is_merging_materials: BoolProperty(name="Is Merging Materials", default=False)
184187

185188
@property
186189
def active_material(self) -> Material | None:
@@ -199,6 +202,7 @@ def active_material(self) -> Material | None:
199202
editing_material_type: str
200203
styles: str
201204
contexts: str
205+
is_merging_materials: bool
202206

203207

204208
class BIMObjectMaterialProperties(PropertyGroup):

‎src/bonsai/bonsai/bim/module/material/ui.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ def draw(self, context):
8181
op = row.operator("bim.enable_editing_material_style", text="", icon="SHADING_RENDERED")
8282
op.material = material_id
8383
row.operator("bim.remove_material", text="", icon="X").material = material_id
84+
if not self.props.is_merging_materials:
85+
row.operator("bim.enable_material_merge", text="", icon="AUTOMERGE_ON")
86+
87+
if self.props.is_merging_materials:
88+
self.draw_merging_ui()
8489

8590
self.draw_editing_ui()
8691
else:
@@ -108,6 +113,19 @@ def draw(self, context):
108113
op.style = style["id"]
109114
op.context = style["context_id"]
110115

116+
def draw_merging_ui(self):
117+
selected_total = sum(1 for m in self.props.materials if m.is_selected and not m.is_category)
118+
target = self.props.active_material
119+
target_name = target.name if target and not target.is_category else "(select a material below)"
120+
box = self.layout.box()
121+
row = box.row(align=True)
122+
row.label(text=f"Merge target: {target_name}", icon="GREASEPENCIL")
123+
row = box.row(align=True)
124+
row.label(text=f"Check materials below to merge into it ({selected_total} checked)", icon="INFO")
125+
row = box.row(align=True)
126+
row.operator("bim.merge_materials", text="Merge Into Active", icon="AUTOMERGE_ON")
127+
row.operator("bim.disable_material_merge", text="Cancel", icon="CANCEL")
128+
111129
def draw_editing_ui(self):
112130
if not self.props.active_material_id:
113131
return
@@ -582,7 +600,10 @@ def draw_item(
582600
else:
583601
row.prop(item, "name", text="", emboss=False)
584602
else:
585-
row.label(text="", icon="BLANK1")
603+
if data.is_merging_materials and material_type == "IfcMaterial":
604+
row.prop(item, "is_selected", text="")
605+
else:
606+
row.label(text="", icon="BLANK1")
586607
if item.ifc_definition_id == data.active_material_id:
587608
row.label(text="", icon="GREASEPENCIL")
588609
if material_type == "IfcMaterialList":

‎src/bonsai/bonsai/core/material.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,23 @@ def remove_material(
6464
return True
6565

6666

67+
def merge_materials(
68+
ifc: type[tool.Ifc],
69+
material_tool: type[tool.Material],
70+
materials: list[ifcopenshell.entity_instance],
71+
merge_into: ifcopenshell.entity_instance,
72+
) -> None:
73+
"""Merge redundant materials into `merge_into`.
74+
75+
Every reference to a material in `materials` (direct assignments, layers,
76+
profiles, constituents, and material lists) is reassigned to
77+
`merge_into`, and the now-redundant materials are removed.
78+
"""
79+
ifc.run("material.merge_materials", materials=materials, merge_into=merge_into)
80+
if material_tool.is_editing_materials():
81+
material_tool.import_material_definitions(material_tool.get_active_material_type())
82+
83+
6784
def remove_material_set(
6885
ifc: type[tool.Ifc], material_tool: type[tool.Material], material: ifcopenshell.entity_instance
6986
) -> None:

‎src/bonsai/test/core/test_material.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ def test_not_removing_a_material_if_it_is_used_in_a_material_set(self, ifc, mate
8484
subject.remove_material(ifc, material, material="material")
8585

8686

87+
class TestMergeMaterials:
88+
def test_merging_materials(self, ifc, material):
89+
ifc.run("material.merge_materials", materials=["source1", "source2"], merge_into="target").should_be_called()
90+
material.is_editing_materials().should_be_called().will_return(False)
91+
subject.merge_materials(ifc, material, materials=["source1", "source2"], merge_into="target")
92+
93+
def test_merging_materials_and_reloading_imported_materials(self, ifc, material):
94+
ifc.run("material.merge_materials", materials=["source1", "source2"], merge_into="target").should_be_called()
95+
material.is_editing_materials().should_be_called().will_return(True)
96+
material.get_active_material_type().should_be_called().will_return("material_type")
97+
material.import_material_definitions("material_type").should_be_called()
98+
subject.merge_materials(ifc, material, materials=["source1", "source2"], merge_into="target")
99+
100+
87101
class TestRemoveMaterialSet:
88102
def test_run(self, ifc, material):
89103
ifc.run("material.remove_material_set", material="material").should_be_called()

‎src/ifcopenshell-python/ifcopenshell/api/material/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
from .edit_material import edit_material
4949
from .edit_profile import edit_profile
5050
from .edit_profile_usage import edit_profile_usage
51+
from .merge_materials import merge_materials
5152
from .remove_constituent import remove_constituent
5253
from .remove_layer import remove_layer
5354
from .remove_list_item import remove_list_item
@@ -77,6 +78,7 @@
7778
"edit_material",
7879
"edit_profile",
7980
"edit_profile_usage",
81+
"merge_materials",
8082
"remove_constituent",
8183
"remove_layer",
8284
"remove_list_item",
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# IfcOpenShell - IFC toolkit and geometry engine
2+
# Copyright (C) 2024 Dion Moult <dion@thinkmoult.com>
3+
#
4+
# This file is part of IfcOpenShell.
5+
#
6+
# IfcOpenShell is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Lesser General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# IfcOpenShell is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU Lesser General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU Lesser General Public License
17+
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
18+
19+
# This file was generated with the assistance of an AI coding tool.
20+
21+
import ifcopenshell
22+
import ifcopenshell.api.material
23+
24+
25+
def merge_materials(
26+
file: ifcopenshell.file,
27+
materials: list[ifcopenshell.entity_instance],
28+
merge_into: ifcopenshell.entity_instance,
29+
) -> None:
30+
"""Merges one or more redundant materials into a single designated material
31+
32+
This is typically used to clean up models (often from import processes)
33+
that end up with many duplicate or near-duplicate IfcMaterial
34+
definitions. Every product, type, material layer, material profile,
35+
material constituent, and material list item that currently references
36+
one of `materials` will be repointed to `merge_into` instead. Once a
37+
material has no more references, it is removed, along with any
38+
properties or styles that belonged only to that redundant material (the
39+
definition of `merge_into` "wins").
40+
41+
If `merge_into` is included in `materials`, it is simply skipped, so it
42+
is safe to pass the designated material as part of a "select all, merge"
43+
workflow.
44+
45+
:param materials: The list of redundant IfcMaterial entities to merge and
46+
remove.
47+
:param merge_into: The IfcMaterial entity that should be used instead of
48+
all of `materials`.
49+
:return: None
50+
51+
Example:
52+
53+
.. code:: python
54+
55+
# Two suspiciously similar materials, perhaps from a bad import.
56+
m1 = ifcopenshell.api.material.add_material(model, name="Concrete")
57+
m2 = ifcopenshell.api.material.add_material(model, name="Concrete_2")
58+
59+
wall1 = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall")
60+
wall2 = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall")
61+
ifcopenshell.api.material.assign_material(model, products=[wall1], material=m1)
62+
ifcopenshell.api.material.assign_material(model, products=[wall2], material=m2)
63+
64+
# Both walls will now use m1, and m2 will no longer exist.
65+
ifcopenshell.api.material.merge_materials(model, materials=[m1, m2], merge_into=m1)
66+
"""
67+
materials_to_remove = [m for m in materials if m != merge_into]
68+
if not materials_to_remove:
69+
return
70+
71+
for material in materials_to_remove:
72+
# Repoint any direct "this product is made of this material" relationship.
73+
rel = next(
74+
(
75+
r
76+
for r in file.get_inverse(material)
77+
if r.is_a("IfcRelAssociatesMaterial") and r.RelatingMaterial == material
78+
),
79+
None,
80+
)
81+
if rel is not None and rel.RelatedObjects:
82+
ifcopenshell.api.material.assign_material(
83+
file, products=list(rel.RelatedObjects), type="IfcMaterial", material=merge_into
84+
)
85+
86+
# Repoint material set membership (layers, profiles, constituents, lists).
87+
for inverse in file.get_inverse(material):
88+
if (
89+
inverse.is_a("IfcMaterialLayer")
90+
or inverse.is_a("IfcMaterialProfile")
91+
or inverse.is_a("IfcMaterialConstituent")
92+
):
93+
inverse.Material = merge_into
94+
elif inverse.is_a("IfcMaterialList"):
95+
new_materials = []
96+
for item in inverse.Materials or []:
97+
item = merge_into if item == material else item
98+
if item not in new_materials:
99+
new_materials.append(item)
100+
inverse.Materials = new_materials
101+
102+
# Whatever is left (e.g. properties or a style definition that
103+
# belonged only to this now-redundant material) is discarded along
104+
# with the material itself.
105+
ifcopenshell.api.material.remove_material(file, material=material)

0 commit comments

Comments
 (0)