mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-19 11:43:53 +00:00
Bonsai: add "Make Length Per-Instance" for typed/mapped profile types
Adds bim.make_profile_length_per_instance, which converts profile occurrences whose length is "typed" (shared via the type's mapped RepresentationMap) into per-instance editable extrusions. Per selected occurrence it: - swaps the mapped body for its own SweptSolid copy (geometry/length preserved exactly, shared IfcProfileDef kept), leaving the old mapped IfcShapeRepresentation as a harmless orphan rather than deleting it (a delete would either cascade to sibling instances via the shared RepresentationMap or leave Bonsai's Blender-side links dangling); - gives it its own IfcMaterialProfileSetUsage, since occurrences that only inherit the type's IfcMaterialProfileSet report get_usage_type()==None and never get the per-instance Length UI. Sibling instances and the shared MappingSource/MappedRepresentation are left untouched. The operator is idempotent: re-running repairs partial conversions (e.g. adds a missing usage to an already-un-mapped occurrence). A notice + "Make Length Per-Instance" button in the Type panel surfaces the conversion when the active occurrence is still typed-length. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -151,6 +151,7 @@ classes = (
|
||||
profile.EditExtrusionAxis,
|
||||
profile.EnableEditingExtrusionAxis,
|
||||
profile.ExtendProfile,
|
||||
profile.MakeProfileLengthPerInstance,
|
||||
profile.RecalculateProfile,
|
||||
profile.Rotate90,
|
||||
profile.PatchNonParametricMepSegment,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user