From 2817009d5969a8cf2c9bdeb1be2c6d546b0d2a30 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 12 Jul 2026 10:16:38 +0300 Subject: [PATCH] Bonsai: preserve material usage direction across a type change (#6676) Changing an element's type flattened its material layer set direction, e.g. a covering (or wall) with LayerSetDirection AXIS2 became AXIS3. The underlying ifcopenshell.api recreates the IfcMaterialLayerSetUsage from scratch and defaults its direction from the occurrence class (AXIS3_CLASSES includes IfcCovering). Bonsai's core.assign_type already records the old usage attributes and restores them, but the restore was gated behind `model.get_usage_type(type)`, which returns None for classes it does not special-case (e.g. IfcCoveringType), so the restore was skipped and the AXIS3 default stuck. Ungate the restore so it runs whenever usage attributes were recorded, independent of get_usage_type(type). restore_material_usage_attributes is self-guarding (it only writes when the element still carries a usage of the recorded type), so this is safe for types with no recognized usage. Also drops a redundant get_usage_type call. Verified live in headless Blender on the reporter's model: an IfcCovering with a manually set AXIS2 usage kept AXIS2 (and its DirectionSense/offset) across a type change, where before it flattened to AXIS3; reassigning to the same or another type preserves the direction with no regression. Core test_type.py: 5 passed (incl. a new regression test). Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Opus 4.8 --- src/bonsai/bonsai/core/type.py | 8 +++++++- src/bonsai/test/core/test_type.py | 23 +++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/core/type.py b/src/bonsai/bonsai/core/type.py index cf8f3effbe..8ee20c2d5c 100644 --- a/src/bonsai/bonsai/core/type.py +++ b/src/bonsai/bonsai/core/type.py @@ -36,7 +36,13 @@ def assign_type( usage_attributes = type_tool.record_material_usage_attributes(element) ifc.run("type.assign_type", related_objects=[element], relating_type=type) obj = ifc.get_object(element) - if (usage := model.get_usage_type(type)) and usage_attributes: + # Reassigning the type recreates the material usage from scratch, defaulting + # its LayerSetDirection/DirectionSense/offset to the values derived from the + # occurrence class (e.g. AXIS3 for IfcCovering). Restore the recorded usage + # attributes so a manually-set direction (e.g. AXIS2) is preserved. The + # restore is a no-op when the element no longer carries a matching usage, so + # it is safe regardless of what get_usage_type() reports for the new type. + if usage_attributes: type_tool.restore_material_usage_attributes(element, usage_attributes) if (usage := model.get_usage_type(type)) == "PROFILE": model.regenerate_profile(obj) diff --git a/src/bonsai/test/core/test_type.py b/src/bonsai/test/core/test_type.py index 031e414f95..801e4619eb 100644 --- a/src/bonsai/test/core/test_type.py +++ b/src/bonsai/test/core/test_type.py @@ -24,7 +24,7 @@ class TestAssignType: def test_assigning_and_switching_to_an_existing_type_data(self, ifc, model, type): type.record_material_usage_attributes("element").should_be_called().will_return(None) ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called() - model.get_usage_type("type").should_be_called(2).will_return(None) + model.get_usage_type("type").should_be_called().will_return(None) ifc.get_object("type").should_be_called().will_return("type_obj") type.get_object_data("type_obj").should_be_called().will_return("type_obj_data") type.change_object_data("obj", "type_obj_data", is_global=False).should_be_called() @@ -35,13 +35,32 @@ class TestAssignType: def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, model, type): type.record_material_usage_attributes("element").should_be_called().will_return(None) ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called() - model.get_usage_type("type").should_be_called(2).will_return(None) + model.get_usage_type("type").should_be_called().will_return(None) ifc.get_object("type").should_be_called().will_return("type_obj") type.get_object_data("type_obj").should_be_called().will_return(None) ifc.get_object("element").should_be_called().will_return("obj") type.disable_editing("obj").should_be_called() subject.assign_type(ifc, model, type, element="element", type="type") + def test_restoring_recorded_material_usage_attributes_when_the_type_has_no_recognized_usage( + self, ifc, model, type + ): + # Regression test for #6676: changing the type of an element carrying a + # material usage (e.g. an IfcCovering with LayerSetDirection AXIS2) + # recreates the usage with a class-default direction. The recorded usage + # attributes must be restored even when get_usage_type() does not + # recognize the new type's class (returns None), so the direction is + # preserved instead of flattening (AXIS2 -> AXIS3). + type.record_material_usage_attributes("element").should_be_called().will_return({"type": "usage"}) + ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called() + ifc.get_object("element").should_be_called().will_return("obj") + type.restore_material_usage_attributes("element", {"type": "usage"}).should_be_called() + model.get_usage_type("type").should_be_called().will_return(None) + ifc.get_object("type").should_be_called().will_return("type_obj") + type.get_object_data("type_obj").should_be_called().will_return(None) + type.disable_editing("obj").should_be_called() + subject.assign_type(ifc, model, type, element="element", type="type") + class TestPurgeUnusedTypes: def test_purge_types_obj_found(self, ifc, type, geometry):