diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index d59cd6d260..12bab0bb63 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -151,6 +151,7 @@ classes = ( profile.EditExtrusionAxis, profile.EnableEditingExtrusionAxis, profile.ExtendProfile, + profile.MakeProfileLengthPerInstance, profile.RecalculateProfile, profile.Rotate90, profile.PatchNonParametricMepSegment, diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index efbf41900d..ae16acf1a1 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -21,8 +21,10 @@ from math import atan2, degrees, pi, radians from typing import TYPE_CHECKING, Any, Literal, Optional, Union import bpy +import numpy as np import ifcopenshell import ifcopenshell.api.geometry +import ifcopenshell.api.material import ifcopenshell.api.pset import ifcopenshell.api.type import ifcopenshell.util.element @@ -871,6 +873,103 @@ class RecalculateProfile(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} +class MakeProfileLengthPerInstance(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.make_profile_length_per_instance" + bl_label = "Make Profile Length Per Instance" + bl_description = ( + "Un-map typed/shared profile geometry so each selected occurrence gets its own body " + "(the extrusion length lives on the instance instead of being typed/shared via a " + "RepresentationMap). Geometry is preserved exactly and sibling instances are left untouched" + ) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return bool(context.selected_objects) + + def _execute(self, context): + ifc_file = tool.Ifc.get() + unmapped = 0 + print(f"[make_length_per_instance] {len(context.selected_objects)} object(s) selected") + for obj in context.selected_objects: + element = tool.Ifc.get_entity(obj) + if not element: + print(f"[make_length_per_instance] SKIP {getattr(obj, 'name', obj)!r}: not an IFC element") + continue + if self.unmap_element_body(ifc_file, element, obj): + unmapped += 1 + print(f"[make_length_per_instance] done: un-mapped {unmapped} object(s)") + self.report({"INFO"}, f"Length made per-instance for {unmapped} object(s)") + return {"FINISHED"} + + def unmap_element_body( + self, ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance, obj: bpy.types.Object + ) -> bool: + name = getattr(obj, "name", obj) + body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if not body: + print(f"[make_length_per_instance] SKIP #{element.id()} {name!r}: no Body/MODEL_VIEW representation") + return False + + changed = False + + # (1) Un-map the body if it is a shared/mapped representation. + mapped_items = [i for i in (body.Items or []) if i.is_a("IfcMappedItem")] + if mapped_items: + # Only identity mapping transforms are handled; refuse rather than move geometry. + for mi in mapped_items: + if not np.allclose(ifcopenshell.util.placement.get_mappeditem_transformation(mi), np.eye(4)): + print(f"[make_length_per_instance] SKIP #{element.id()} {name!r}: non-identity mapping transform") + return False + print(f"[make_length_per_instance] un-mapping body of #{element.id()} {name!r} (body #{body.id()})") + + # Give this object its own mesh so sibling instances (sharing the mapped mesh) are not disturbed. + if obj.data is not None and obj.data.users > 1: + obj.data = obj.data.copy() + + # Per-instance copy of the mapped geometry items, keeping the shared IfcProfileDef. + new_items = [] + for mi in mapped_items: + for src_item in (mi.MappingSource.MappedRepresentation.Items or []): + new_items.append(ifcopenshell.util.element.copy_deep(ifc_file, src_item, exclude=["IfcProfileDef"])) + + # New per-instance SweptSolid body replacing the mapped wrapper; retarget the product shape to it. + # The old mapped IfcShapeRepresentation is left as a harmless orphan (purgeable later) rather than + # deleted -- deleting it would either cascade to siblings (via the shared RepresentationMap) or + # leave Bonsai's Blender-side links dangling. + new_body = ifcopenshell.util.element.copy(ifc_file, body) + new_body.Items = new_items + new_body.RepresentationType = "SweptSolid" + for inverse in ifc_file.get_inverse(body): + ifcopenshell.util.element.replace_attribute(inverse, body, new_body) + bonsai.core.geometry.switch_representation( + tool.Ifc, tool.Geometry, obj=obj, representation=new_body, apply_openings=True + ) + changed = True + else: + print(f"[make_length_per_instance] #{element.id()} {name!r}: body already per-instance ({body.RepresentationType})") + + # (2) Give the occurrence its OWN IfcMaterialProfileSetUsage if it only inherits one. Bonsai gates the + # per-instance profile-editing UI on get_usage_type(should_inherit=False) == "PROFILE", so an occurrence + # that merely inherits the type's IfcMaterialProfileSet never shows the Length control. This runs even + # when the body was already un-mapped, so re-running repairs earlier partial conversions. + if not ifcopenshell.util.element.get_material(element, should_inherit=False): + profile_set = ifcopenshell.util.element.get_material(element, should_inherit=True) + if profile_set and profile_set.is_a("IfcMaterialProfileSet"): + usage = ifcopenshell.api.material.assign_material( + ifc_file, products=[element], type="IfcMaterialProfileSetUsage", material=profile_set + ) + usage = usage[0] if isinstance(usage, (list, tuple)) else usage + if usage is not None and usage.is_a("IfcMaterialProfileSetUsage"): + usage.CardinalPoint = 5 # geometric centroid (Bonsai default) + print(f"[make_length_per_instance] #{element.id()} {name!r}: added per-instance IfcMaterialProfileSetUsage") + changed = True + + if not changed: + print(f"[make_length_per_instance] #{element.id()} {name!r}: nothing to do (already per-instance with its own usage)") + return changed + + class DumbProfileRecalculator: def recalculate(self, profiles): "`profiles` is a list of blender profile objects" diff --git a/src/bonsai/bonsai/bim/module/type/data.py b/src/bonsai/bonsai/bim/module/type/data.py index 25beb2d36d..f94a58bdb8 100644 --- a/src/bonsai/bonsai/bim/module/type/data.py +++ b/src/bonsai/bonsai/bim/module/type/data.py @@ -18,6 +18,7 @@ import bpy import ifcopenshell.util.element +import ifcopenshell.util.representation import ifcopenshell.util.type from ifcopenshell.util.doc import get_entity_doc @@ -44,9 +45,28 @@ class TypeData: "total_instances": cls.total_instances(), "relating_type": cls.relating_type(), "relating_type_attributes": cls.relating_type_attributes(), + "is_typed_length_profile": cls.is_typed_length_profile(), } ) + @classmethod + def is_typed_length_profile(cls): + """True if the active occurrence is a profile-based element whose length is 'typed'/shared + (its body is a mapped/shared representation and/or it only inherits the type's material + profile set), so it could be converted to per-instance editable length.""" + if not (obj := bpy.context.active_object): + return False + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcProduct"): + return False + material = ifcopenshell.util.element.get_material(element, should_inherit=True) + if not (material and "Profile" in material.is_a()): # IfcMaterialProfileSet(Usage) + return False + has_own_usage = bool(ifcopenshell.util.element.get_material(element, should_inherit=False)) + body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + body_is_mapped = bool(body and any(i.is_a("IfcMappedItem") for i in (body.Items or []))) + return (not has_own_usage) or body_is_mapped + @classmethod def relating_type_classes(cls): results = [] diff --git a/src/bonsai/bonsai/bim/module/type/ui.py b/src/bonsai/bonsai/bim/module/type/ui.py index 1848858953..2b50fb0c39 100644 --- a/src/bonsai/bonsai/bim/module/type/ui.py +++ b/src/bonsai/bonsai/bim/module/type/ui.py @@ -107,6 +107,14 @@ class BIM_PT_type(Panel): row.label(text="No Relating Type") row.operator("bim.enable_editing_type", icon="GREASEPENCIL", text="") + # Offer converting a typed/shared profile length to a per-instance editable length. + if TypeData.data.get("is_typed_length_profile"): + row = layout.row(align=True) + row.label(text="Length is typed (shared)", icon="INFO") + row.operator( + "bim.make_profile_length_per_instance", icon="ARROW_LEFTRIGHT", text="Make Length Per-Instance" + ) + class BIM_PT_type_attributes(Panel): bl_label = "Type Attributes"