Bonsai: split multi-material profile sets into per-item shape aspects

DumbProfileGenerator only ever used IfcMaterialProfileSet.CompositeProfile,
or otherwise the first IfcMaterialProfile, when instantiating an occurrence
from a profile-based type, silently discarding every other material profile
in the set (e.g. a steel beam with a timber cladding profile would only
render the steel part, with no way to recover the cladding).

When there is no composite profile but more than one material profile, the
occurrence's Body representation is now built with one extruded item per
IfcMaterialProfile, all sharing a single placement so profiles authored with
a relative offset (e.g. a cladding profile nested against a structural one)
stay correctly positioned. Each item is tagged with an IfcShapeAspect named
after its material profile and gets that material's style applied directly,
mirroring the item/shape-aspect pattern already used by the door and window
representation generators. The element's material assignment keeps
referencing the type's IfcMaterialProfileSetUsage unchanged, since that
already carries the per-profile material; the shape aspect only correlates
each geometry item back to it.

The composite-profile case (an authored single silhouette, e.g. for
structural analysis) is untouched and still takes priority when present.

Fixes #2052.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Petru Conduraru
2026-07-16 17:53:41 +03:00
parent d9d1824886
commit e29474cf23
+90 -7
View File
@@ -24,6 +24,7 @@ import bpy
import ifcopenshell
import ifcopenshell.api.geometry
import ifcopenshell.api.pset
import ifcopenshell.api.style
import ifcopenshell.api.type
import ifcopenshell.util.element
import ifcopenshell.util.placement
@@ -156,13 +157,25 @@ class DumbProfileGenerator:
tool.Ifc.get(), product=element, representation=representation
)
representation = ifcopenshell.api.geometry.add_profile_representation(
tool.Ifc.get(),
context=self.body_context,
profile=self.profile_set.CompositeProfile or self.profile_set.MaterialProfiles[0].Profile,
cardinal_point=self.cardinal_point,
depth=self.depth,
)
material_profiles = list(self.profile_set.MaterialProfiles or [])
# A composite profile is an explicit, authored silhouette (e.g. for structural
# analysis) and always takes priority when present, matching prior behaviour.
# Only when there's no composite profile AND more than one material profile do
# we split the occurrence into one item per material profile, so each keeps its
# own material instead of collapsing to the first one.
is_multi_material = not self.profile_set.CompositeProfile and len(material_profiles) > 1
if is_multi_material:
representation, items = self.create_multi_material_profile_items(material_profiles)
else:
representation = ifcopenshell.api.geometry.add_profile_representation(
tool.Ifc.get(),
context=self.body_context,
profile=self.profile_set.CompositeProfile or self.profile_set.MaterialProfiles[0].Profile,
cardinal_point=self.cardinal_point,
depth=self.depth,
)
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), product=element, representation=representation)
bonsai.core.geometry.switch_representation(
tool.Ifc,
@@ -171,6 +184,9 @@ class DumbProfileGenerator:
representation=representation,
)
if is_multi_material:
self.tag_material_profile_items(element, representation, items, material_profiles)
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="EPset_Parametric")
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Engine": "Bonsai.DumbProfile"})
@@ -178,6 +194,73 @@ class DumbProfileGenerator:
return obj
def create_multi_material_profile_items(
self, material_profiles: list[ifcopenshell.entity_instance]
) -> tuple[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]:
"""Builds a single Body representation with one extruded item per `IfcMaterialProfile`.
All items share the same placement (derived from the first material profile's
cardinal point / bounding box), mirroring how door/window representations combine
multiple items in a single coordinate frame: each `IfcProfileDef` in a material
profile set is expected to already be positioned relative to that shared origin
(e.g. a cladding profile authored with its own offset `Position`), so reusing one
placement for every item preserves the intended nesting between profiles.
"""
file = tool.Ifc.get()
reference_representation = ifcopenshell.api.geometry.add_profile_representation(
file,
context=self.body_context,
profile=material_profiles[0].Profile,
cardinal_point=self.cardinal_point,
depth=self.depth,
)
reference_item = reference_representation.Items[0]
items = [reference_item]
for material_profile in material_profiles[1:]:
items.append(
file.create_entity(
"IfcExtrudedAreaSolid",
material_profile.Profile,
reference_item.Position,
reference_item.ExtrudedDirection,
reference_item.Depth,
)
)
reference_representation.Items = items
return reference_representation, items
def tag_material_profile_items(
self,
element: ifcopenshell.entity_instance,
representation: ifcopenshell.entity_instance,
items: list[ifcopenshell.entity_instance],
material_profiles: list[ifcopenshell.entity_instance],
) -> None:
"""Tags each item with an `IfcShapeAspect` named after its `IfcMaterialProfile`,
and applies that material's style (if any) directly to the item, following the
same item-level tagging pattern used by the door/window representation generators.
The element's own material assignment is left untouched (it keeps referencing the
type's `IfcMaterialProfileSetUsage`), since that's what carries the per-profile
material information; the shape aspect only correlates geometry items back to it.
"""
file = tool.Ifc.get()
part_of_product = ifcopenshell.util.representation.get_part_of_product(element, self.body_context)
if not part_of_product:
return
for index, (item, material_profile) in enumerate(zip(items, material_profiles)):
material = material_profile.Material
aspect_name = material_profile.Name or (material and material.Name) or f"Profile {index + 1}"
ifcopenshell.api.geometry.add_shape_aspect(
file,
aspect_name,
items=[item],
representation=representation,
part_of_product=part_of_product,
)
if material and (style := ifcopenshell.util.representation.get_material_style(material, self.body_context)):
ifcopenshell.api.style.assign_item_style(file, item=item, style=style)
def create_profile_from_2_points(
self, coords: tuple[Vector, Vector], should_round: bool = False
) -> ProfileFrom2PointsReturn: